claude-code-kanban 4.31.1 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -74,6 +74,8 @@ let agentPollInterval = null;
74
74
  // Inside the hub an inactive app is a display:none iframe whose document.hidden stays false,
75
75
  // so the hub tells us via hub:active. Standalone, or if that message is lost, cck stays active.
76
76
  let hubActive = true;
77
+ // setupEventSource runs before HUB_INTEGRATION connects, so it hands its handler over here.
78
+ let onHubActive = () => {};
77
79
  let missedWhileHidden = false;
78
80
  const isOnScreen = () => hubActive && !document.hidden;
79
81
  function skipOffScreen() {
@@ -182,6 +184,7 @@ function updateUrl() {
182
184
  history.replaceState(null, '', url);
183
185
  persistLastView();
184
186
  syncTerminal();
187
+ schedulePublishSession();
185
188
  }
186
189
 
187
190
  const LAST_VIEW_KEY = 'lastView';
@@ -286,8 +289,10 @@ async function fetchSessions(includeTasks = true) {
286
289
  if (sessionsHash !== lastSessionsHash) {
287
290
  lastSessionsHash = sessionsHash;
288
291
  sessions = mergePlaceholders(newSessions);
292
+ markOpenSessionRead();
289
293
  renderSessions();
290
294
  renderActivityChip();
295
+ schedulePublishSession();
291
296
  }
292
297
  // A list too short to scroll gives no scroll event, so fill it until it can scroll.
293
298
  if (sessionsHasMore) setTimeout(loadMoreIfNearEnd);
@@ -298,6 +303,21 @@ async function fetchSessions(includeTasks = true) {
298
303
  }
299
304
  }
300
305
 
306
+ // The Stop hook leaves _stop.json when a session ends its turn; deleting it marks the session read.
307
+ function markSessionRead(sessionId) {
308
+ const session = sessions.find((s) => s.id === sessionId);
309
+ if (!session?.unread) return;
310
+ session.unread = false;
311
+ document.querySelector(`.session-item[data-session-id="${CSS.escape(sessionId)}"]`)?.classList.remove('unread');
312
+ fetch(`/api/sessions/${encodeURIComponent(sessionId)}/read`, { method: 'POST' }).catch((e) =>
313
+ console.error('[markSessionRead]', e),
314
+ );
315
+ }
316
+
317
+ function markOpenSessionRead() {
318
+ if (currentSessionId && viewMode === 'session' && isOnScreen()) markSessionRead(currentSessionId);
319
+ }
320
+
301
321
  async function loadSearchTasks() {
302
322
  const newTasks = await fetch('/api/tasks/all').then((r) => r.json());
303
323
  const tasksHash = JSON.stringify(newTasks);
@@ -426,29 +446,55 @@ function isActiveSession(s) {
426
446
  function isSessionLive(s) {
427
447
  return !!s.hasRecentLog && Date.now() - Date.parse(s.modifiedAt) <= LIVE_INDICATOR_MS;
428
448
  }
449
+ function isJustFinished(s) {
450
+ return (
451
+ !isWaitingSession(s) &&
452
+ !isActiveSession(s) &&
453
+ !!s.hasRecentActivity &&
454
+ Date.now() - Date.parse(s.modifiedAt) < JUST_NOW_MS
455
+ );
456
+ }
457
+
458
+ // The JUST_NOW_MS mark is a client-side boundary; no server update arrives when a session crosses it.
459
+ let justFinishedTimer = null;
460
+ function scheduleJustFinishedExpiry() {
461
+ clearTimeout(justFinishedTimer);
462
+ let next = Infinity;
463
+ for (const s of sessions) {
464
+ if (isJustFinished(s)) next = Math.min(next, Date.parse(s.modifiedAt) + JUST_NOW_MS - Date.now());
465
+ }
466
+ if (next === Infinity) return;
467
+ justFinishedTimer = setTimeout(() => {
468
+ if (!skipOffScreen()) renderSessionViews();
469
+ }, next + 50);
470
+ }
471
+
472
+ function renderSessionViews() {
473
+ renderSessions();
474
+ if (isSessionPickerOpen()) renderSessionPicker();
475
+ }
429
476
 
430
477
  const ACTIVITY_PREDICATES = {
431
478
  waiting: isWaitingSession,
432
479
  active: isActiveSession,
480
+ terminal: (s) => runningTerminals.has(s.id),
433
481
  };
434
482
 
435
- let lastChipKey = '';
483
+ let lastChipHtml = '';
436
484
 
437
485
  function renderActivityChip() {
438
486
  const container = document.getElementById('activity-chips');
439
487
  if (!container) return;
440
488
 
441
- let waiting = 0;
442
- let active = 0;
489
+ const counts = { waiting: 0, active: 0, terminal: 0 };
443
490
  for (const s of sessions) {
444
491
  if (dismissedSessionIds.has(s.id)) continue;
445
- if (s.hasWaitingForUser) waiting++;
446
- else if (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents) active++;
492
+ for (const kind in counts) if (ACTIVITY_PREDICATES[kind](s)) counts[kind]++;
447
493
  }
448
-
449
- const key = `${waiting}|${active}|${dismissedSessionIds.size}|${[...activityFilter].sort().join(',')}`;
450
- if (key === lastChipKey) return;
451
- lastChipKey = key;
494
+ const { waiting, active, terminal: terminals } = counts;
495
+ const showTerminals = terminalAvailable();
496
+ const maxTerminals = appConfig.terminal?.maxSessions;
497
+ const nearCap = showTerminals && runningTerminals.size >= maxTerminals * 0.8;
452
498
 
453
499
  const chips = [
454
500
  {
@@ -464,14 +510,27 @@ function renderActivityChip() {
464
510
  title: `${active} session${active === 1 ? '' : 's'} with running work or recent activity`,
465
511
  },
466
512
  ];
513
+ if (showTerminals) {
514
+ chips.push({
515
+ kind: 'terminal',
516
+ count: terminals,
517
+ label: nearCap ? `${runningTerminals.size}/${maxTerminals}` : `${terminals}`,
518
+ title: nearCap
519
+ ? `${runningTerminals.size} of ${maxTerminals} terminals open; end some to start more`
520
+ : `${terminals} session${terminals === 1 ? '' : 's'} running in a terminal here`,
521
+ icon: ICON_TERMINAL,
522
+ warn: nearCap,
523
+ });
524
+ }
467
525
 
468
- container.innerHTML = chips
526
+ const html = chips
469
527
  .map((c) => {
470
528
  const isOn = activityFilter.has(c.kind);
471
529
  const classes = [
472
530
  'activity-chip',
473
531
  `activity-${c.kind}`,
474
- c.count === 0 ? 'activity-zero' : '',
532
+ c.count === 0 && !c.warn ? 'activity-zero' : '',
533
+ c.warn ? 'activity-warn' : '',
475
534
  isOn ? 'activity-filter-on' : '',
476
535
  ]
477
536
  .filter(Boolean)
@@ -483,12 +542,15 @@ function renderActivityChip() {
483
542
  onclick="setActivityFilter('${escAttrJs(c.kind)}')"
484
543
  aria-pressed="${isOn ? 'true' : 'false'}"
485
544
  title="${escapeHtml(c.title + hint)}">
486
- <span class="activity-dot"></span>
545
+ ${c.icon ? `<span class="activity-icon">${c.icon}</span>` : '<span class="activity-dot"></span>'}
487
546
  <span class="activity-label">${escapeHtml(c.label)}</span>
488
547
  </button>
489
548
  `;
490
549
  })
491
550
  .join('');
551
+ if (html === lastChipHtml) return;
552
+ lastChipHtml = html;
553
+ container.innerHTML = html;
492
554
  }
493
555
 
494
556
  function toggleActivityKind(kind) {
@@ -524,6 +586,7 @@ let lastCurrentTasksHash = '';
524
586
 
525
587
  async function fetchTasks(sessionId) {
526
588
  try {
589
+ const refresh = viewMode === 'session' && sessionId === currentSessionId;
527
590
  viewMode = 'session';
528
591
  document.getElementById('message-toggle')?.style.removeProperty('display');
529
592
  const res = await fetch(`/api/sessions/${sessionId}`);
@@ -532,9 +595,7 @@ async function fetchTasks(sessionId) {
532
595
  const newTasks = await res.json();
533
596
 
534
597
  const hash = JSON.stringify(newTasks);
535
- if (sessionId === currentSessionId && hash === lastCurrentTasksHash) {
536
- return;
537
- }
598
+ if (sessionId === currentSessionId && hash === lastCurrentTasksHash) return;
538
599
  lastCurrentTasksHash = hash;
539
600
 
540
601
  currentTasks = newTasks;
@@ -548,18 +609,24 @@ async function fetchTasks(sessionId) {
548
609
  if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
549
610
  revealedStorageSessionId = null;
550
611
  }
551
- if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
612
+ if (currentSessionId && currentSessionId !== sessionId && deferredPinPlacement.delete(currentSessionId))
613
+ expandPinnedFor(currentSessionId);
552
614
  if (lastSessionId !== sessionId) setSwapPair(sessionId, lastSessionId);
553
615
  const switched = sessionId !== currentSessionId;
554
616
  currentSessionId = sessionId;
617
+ markOpenSessionRead();
555
618
  if (switched) autoRevealLog(sessionId, newTasks.length === 0);
556
- currentPins = loadPins(sessionId);
557
- ownerFilter = '';
558
- resetMessageScrollState();
559
- for (const k of Object.keys(ownerColorCache)) delete ownerColorCache[k];
560
- for (const k of Object.keys(teamColorMap)) delete teamColorMap[k];
561
- sessionJustSelected = true;
562
- resetAgentState();
619
+ // A task change in the open session must not reset the messages, the agents or the owner
620
+ // filter: that reset rebuilds the panels and reads as a reload after each card move.
621
+ if (!refresh) {
622
+ currentPins = loadPins(sessionId);
623
+ ownerFilter = '';
624
+ resetMessageScrollState();
625
+ for (const k of Object.keys(ownerColorCache)) delete ownerColorCache[k];
626
+ for (const k of Object.keys(teamColorMap)) delete teamColorMap[k];
627
+ sessionJustSelected = true;
628
+ resetAgentState();
629
+ }
563
630
  updateUrl();
564
631
  renderSession();
565
632
  renderSessions();
@@ -579,6 +646,7 @@ async function fetchTasks(sessionId) {
579
646
  const WAITING_TTL_MS = 30 * 60 * 1000;
580
647
  const AGENT_LOG_MAX = 8;
581
648
  const LIVE_INDICATOR_MS = 10 * 1000;
649
+ const JUST_NOW_MS = 60 * 1000;
582
650
  // #endregion
583
651
 
584
652
  function resetAgentState() {
@@ -1658,6 +1726,7 @@ const ICON_AGENT_ACTIVE =
1658
1726
  '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>';
1659
1727
  const ICON_TERMINAL =
1660
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
+ const TERMINAL_TITLE = 'Running in a terminal here';
1661
1730
  const ICON_CHAT =
1662
1731
  '<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>';
1663
1732
  const TOOL_ICONS = {
@@ -1913,6 +1982,7 @@ function toggleSessionPin(sessionId) {
1913
1982
  } else {
1914
1983
  pinnedSessionIds.add(sessionId);
1915
1984
  if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
1985
+ expandPinnedFor(sessionId);
1916
1986
  }
1917
1987
  savePinnedSessions();
1918
1988
  offloadSessionPin(sessionId);
@@ -1951,6 +2021,7 @@ function handleSessionPinEvent({ id, state }) {
1951
2021
  pinnedSessionIds.add(id);
1952
2022
  stickySessionIds.add(id);
1953
2023
  }
2024
+ expandPinnedFor(id);
1954
2025
  savePinnedSessions();
1955
2026
  renderSessions();
1956
2027
  }
@@ -2411,6 +2482,10 @@ function showToast(msg, type, action) {
2411
2482
  _toastTimer = setTimeout(hide, action ? 8000 : 2000);
2412
2483
  }
2413
2484
 
2485
+ function sendMessageAddress(peerName) {
2486
+ return `SendMessage(${peerName})`;
2487
+ }
2488
+
2414
2489
  async function copyWithFeedback(text, btn) {
2415
2490
  if (btn.dataset.copying) return;
2416
2491
  try {
@@ -3296,7 +3371,7 @@ function getFilteredSessions() {
3296
3371
  // Skip pinned sessions — they are prepended separately below (lines ~2180) to preserve stable position.
3297
3372
  const filteredIds = new Set(filteredSessions.map((s) => s.id));
3298
3373
  for (const id of [revealedPlanSessionId, revealedStorageSessionId, currentSessionId]) {
3299
- if (id && !filteredIds.has(id) && !isAnyPinned(id)) {
3374
+ if (id && !filteredIds.has(id) && !(isAnyPinned(id) && activityFilter.size === 0)) {
3300
3375
  const session = sessions.find((s) => s.id === id);
3301
3376
  if (session) {
3302
3377
  const insertAt = filteredSessions.findIndex((s) => s.modifiedAt < session.modifiedAt);
@@ -3310,12 +3385,19 @@ function getFilteredSessions() {
3310
3385
  // The open session keeps its row even when it belongs to another project, so that
3311
3386
  // revealing one by id has something to scroll to. Same exemption the active branch
3312
3387
  // above already makes, for the same reason.
3313
- filteredSessions = filteredSessions.filter((s) => matchesProjectFilter(s.project) || s.id === currentSessionId);
3388
+ filteredSessions = filteredSessions.filter(
3389
+ (s) =>
3390
+ matchesProjectFilter(s.project) ||
3391
+ s.id === currentSessionId ||
3392
+ (filterProject === '__recent__' && isAnyPinned(s.id)),
3393
+ );
3314
3394
  }
3315
3395
 
3316
3396
  if (activityFilter.size > 0) {
3317
3397
  const preds = [...activityFilter].map((k) => ACTIVITY_PREDICATES[k]).filter(Boolean);
3318
- if (preds.length) filteredSessions = filteredSessions.filter((s) => preds.some((p) => p(s)));
3398
+ if (preds.length) {
3399
+ filteredSessions = filteredSessions.filter((s) => s.id === currentSessionId || preds.some((p) => p(s)));
3400
+ }
3319
3401
  }
3320
3402
 
3321
3403
  if (searchQuery) {
@@ -3377,6 +3459,7 @@ function renderSessions() {
3377
3459
  // Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
3378
3460
  // half-typed group name, and the SSE path can fire at any moment — defer instead.
3379
3461
  if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
3462
+ scheduleJustFinishedExpiry();
3380
3463
  refreshProjectList();
3381
3464
 
3382
3465
  // Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
@@ -3445,10 +3528,19 @@ function renderSessions() {
3445
3528
  const linkedDocsCount = getSessionPreviewPaths(session.id).length;
3446
3529
  const bookmarksCount = loadPins(session.id).length;
3447
3530
  const hasScratchpad = _hasScratchpad(_sessionScratchpadKey(session.id));
3448
- const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
3531
+ const tempClass =
3532
+ session.hasRecentLog || session.hasRecentActivity || session.inProgress || session.hasWaitingForUser
3533
+ ? 'warm'
3534
+ : 'stale';
3535
+ // Open tasks keep an idle session warm, but the green dot is for a session that is working.
3536
+ const idleClass =
3537
+ tempClass === 'warm' && !session.hasWaitingForUser && !session.hasRecentLog && !session.hasRunningAgents
3538
+ ? 'idle'
3539
+ : '';
3540
+ const justFinishedClass = isJustFinished(session) ? 'just-finished' : '';
3449
3541
  const sid = escAttrJs(session.id);
3450
3542
  return `
3451
- <button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
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)}">
3452
3544
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
3453
3545
  <div class="session-name">${escapeHtml(sessionName)}</div>
3454
3546
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
@@ -3466,7 +3558,7 @@ function renderSessions() {
3466
3558
  ${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
3467
3559
  ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escAttrJs(session.planSourceSessionId)}')">${ICON_PLAN}</span>` : ''}
3468
3560
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
3469
- ${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="Running in a terminal here">${ICON_TERMINAL}</span>` : ''}
3561
+ ${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>` : ''}
3470
3562
  ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
3471
3563
  ${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
3472
3564
  ${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
@@ -3506,12 +3598,13 @@ function renderSessions() {
3506
3598
  return `<span class="group-count" title="${active} active / ${arr.length} total">${active > 0 ? `<span class="group-count-active">${active}</span><span class="group-count-sep">/</span>` : ''}${arr.length}</span>`;
3507
3599
  };
3508
3600
  // `ungroup` marks the label as the drop target for pulling an item out of a named group.
3509
- const sectionHtml = (key, text, ungroup, body) => {
3601
+ const sectionHtml = (key, text, ungroup, body, empty = false) => {
3510
3602
  const collapsed = collapsedProjectGroups.has(key);
3511
3603
  const escPath = escapeHtml(key);
3512
3604
  const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
3513
- return `<div class="sg-section-label sg-section-toggle${ungroup ? ' sg-ungroup-zone' : ''}${collapsed ? ' collapsed' : ''}" data-group-path="${escPath}" title="${escapeHtml(title)}">${groupChevronSvg(10)}<span>${text}</span></div>
3514
- <div class="sg-section-body${collapsed ? ' collapsed' : ''}" data-group-path="${escPath}">${body}</div>`;
3605
+ const emptyCls = empty ? ' sg-section-empty' : '';
3606
+ return `<div class="sg-section-label sg-section-toggle${ungroup ? ' sg-ungroup-zone' : ''}${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}" title="${escapeHtml(title)}">${groupChevronSvg(10)}<span>${text}</span></div>
3607
+ <div class="sg-section-body${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}">${body}</div>`;
3515
3608
  };
3516
3609
 
3517
3610
  const renderGroupSessions = (sessions, pinKey) => {
@@ -3521,7 +3614,7 @@ function renderSessions() {
3521
3614
  const gIdlePinned = gPinned.filter((s) => !isSessionActive(s));
3522
3615
  const gUnpinned = sessions.filter((s) => !isPlacedPinned(s.id) || isSessionActive(s) || isPlacedSticky(s.id));
3523
3616
  const pinCollapsed = collapsedProjectGroups.has(pinKey);
3524
- if (gIdlePinned.length === 0 && !pinCollapsed) return gUnpinned.map(renderSessionCard).join('');
3617
+ if (gIdlePinned.length === 0) return gUnpinned.map(renderSessionCard).join('');
3525
3618
  return (
3526
3619
  '<div class="pinned-sub-section">' +
3527
3620
  '<div class="pinned-sub-header' +
@@ -3732,14 +3825,15 @@ function renderSessions() {
3732
3825
  }
3733
3826
  }
3734
3827
 
3735
- html += sectioned ? sectionHtml(SECTION_PROJECTS, 'Projects', true, projectsHtml) : projectsHtml;
3828
+ const noProjects = sortedGroups.length === 0 && ungrouped.length === 0;
3829
+ html += sectioned ? sectionHtml(SECTION_PROJECTS, 'Projects', true, projectsHtml, noProjects) : projectsHtml;
3736
3830
 
3737
3831
  sessionsList.innerHTML = html;
3738
3832
  } else {
3739
3833
  const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
3740
3834
  const restHtml =
3741
3835
  sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
3742
- const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml) : restHtml;
3836
+ const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
3743
3837
  sessionsList.innerHTML = html + sgSectionHtml(true) + tail;
3744
3838
  }
3745
3839
 
@@ -3788,8 +3882,7 @@ function renderSession() {
3788
3882
  // happened to open the info modal or zen panel.
3789
3883
  loadSessionPads(currentSessionId);
3790
3884
 
3791
- const displayName =
3792
- session.customTitle || session.name || session.gitBranch || session.description || currentSessionId;
3885
+ const displayName = session.name || session.description || currentSessionId;
3793
3886
 
3794
3887
  sessionTitle.textContent = displayName;
3795
3888
 
@@ -3842,8 +3935,17 @@ function renderProjectView() {
3842
3935
  renderSessions();
3843
3936
  }
3844
3937
 
3938
+ // Claude Code keeps a blocker's id in blockedBy after the blocker completes.
3939
+ function openBlockers(task) {
3940
+ 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
+ );
3944
+ }
3945
+
3845
3946
  function renderTaskCard(task) {
3846
- const isBlocked = task.blockedBy && task.blockedBy.length > 0;
3947
+ const blockers = openBlockers(task);
3948
+ const isBlocked = blockers.length > 0;
3847
3949
  const useSlug = viewMode === 'project';
3848
3950
  const taskId = useSlug ? `${(task._taskDir || task.sessionId || '')?.slice(0, 4)}-${task.id}` : task.id;
3849
3951
  const statusClass = task.status.replace('_', '-');
@@ -3877,7 +3979,7 @@ function renderTaskCard(task) {
3877
3979
  </div>
3878
3980
  <div class="task-title">${escapeHtml(task.subject)}</div>
3879
3981
  ${task.status === 'in_progress' && task.activeForm ? `<div class="task-active">${escapeHtml(task.activeForm)}</div>` : ''}
3880
- ${isBlocked ? `<div class="task-blocked">Waiting on ${task.blockedBy.map((id) => `#${id}`).join(', ')}</div>` : ''}
3982
+ ${isBlocked ? `<div class="task-blocked">Waiting on ${blockers.map((id) => `#${id}`).join(', ')}</div>` : ''}
3881
3983
  ${task.description ? `<div class="task-desc">${escapeHtml(task.description.split('\n')[0])}</div>` : ''}
3882
3984
  </div>
3883
3985
  `;
@@ -3960,7 +4062,7 @@ function renderKanban() {
3960
4062
  // away mid-typing -- leave the column alone until the input is gone.
3961
4063
  if (!addingTask) {
3962
4064
  const addTile = canAddTask()
3963
- ? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this)">${plusIcon}<span>Add task</span></button>`
4065
+ ? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this)" title="You can also ask the agent to add tasks with its TaskCreate tool. Make sure CLAUDE_CODE_ENABLE_TASKS is set in its environment.">${plusIcon}<span>Add task</span></button>`
3964
4066
  : '';
3965
4067
  writes.push([
3966
4068
  pendingTasks,
@@ -4143,17 +4245,21 @@ async function onColumnDrop(e) {
4143
4245
  (t) => t.id === taskId && (t._taskDir === sessionId || (t.sessionId || currentSessionId) === sessionId),
4144
4246
  );
4145
4247
  if (!task || task.status === newStatus) return;
4248
+ const oldStatus = task.status;
4249
+ task.status = newStatus;
4250
+ renderKanban();
4146
4251
  try {
4147
4252
  const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
4148
4253
  method: 'PUT',
4149
4254
  headers: { 'Content-Type': 'application/json' },
4150
4255
  body: JSON.stringify({ status: newStatus }),
4151
4256
  });
4152
- if (res.ok) {
4153
- task.status = newStatus;
4154
- renderKanban();
4155
- }
4257
+ if (res.ok) return;
4156
4258
  } catch (_) {}
4259
+ if (task.status === newStatus) {
4260
+ task.status = oldStatus;
4261
+ renderKanban();
4262
+ }
4157
4263
  }
4158
4264
 
4159
4265
  //#endregion
@@ -4384,9 +4490,9 @@ function sgHeaderHtml(group, countHtml) {
4384
4490
  const actions = group.transient
4385
4491
  ? `<span class="sg-action sg-keep" title="Keep this group after its sessions end">Keep</span>
4386
4492
  <span class="sg-action sg-delete" title="Delete group (sessions return to Projects)">&times;</span>`
4387
- : `${headerPadBtnHtml(_groupScratchpadKey(group.id), group.name)}
4388
- <span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>
4389
- <span class="sg-action sg-delete" title="Delete group (members return to Projects)">&times;</span>`;
4493
+ : `<span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>
4494
+ <span class="sg-action sg-delete" title="Delete group (members return to Projects)">&times;</span>
4495
+ ${headerPadBtnHtml(_groupScratchpadKey(group.id), group.name)}`;
4390
4496
  return `
4391
4497
  <div ${attrs} data-group-path="${escapeHtml(sgKey(group.id))}">
4392
4498
  ${groupChevronSvg()}
@@ -4899,6 +5005,7 @@ function getNavigableItems() {
4899
5005
  // into the body to reach the blocks inside.
4900
5006
  const walkTopLevel = (children) => {
4901
5007
  for (const el of children) {
5008
+ if (el.classList.contains('sg-section-empty')) continue;
4902
5009
  if (
4903
5010
  el.classList.contains('project-group-header') ||
4904
5011
  el.classList.contains('session-group-header') ||
@@ -5007,14 +5114,11 @@ function expandActiveGroups({ onlyNew = false } = {}) {
5007
5114
  // it (pinned, not sticky, idle — see renderGroupSessions), and — since which section holds it
5008
5115
  // depends on the view — every section header. Returns whether anything changed.
5009
5116
  function uncollapseFor(session) {
5010
- const project = session.project || '__ungrouped__';
5011
5117
  const group = sgGroupForSession(session);
5012
- const inPinned = isPlacedPinned(session.id) && !isPlacedSticky(session.id) && !isSessionActive(session);
5013
5118
  const keys = [
5014
- project,
5015
- inPinned && pinKey(project),
5119
+ session.project || '__ungrouped__',
5120
+ ...pinnedCollapseKeys(session),
5016
5121
  group && sgKey(group.id),
5017
- group && inPinned && `__pinned_group_${group.id}__`,
5018
5122
  SECTION_GROUPS,
5019
5123
  SECTION_PROJECTS,
5020
5124
  SECTION_SESSIONS,
@@ -5026,6 +5130,24 @@ function uncollapseFor(session) {
5026
5130
  return changed;
5027
5131
  }
5028
5132
 
5133
+ function pinnedCollapseKeys(session) {
5134
+ if (!isPlacedPinned(session.id) || isPlacedSticky(session.id) || isSessionActive(session)) return [];
5135
+ const group = sgGroupForSession(session);
5136
+ const host = sgHostOf(group, session);
5137
+ return [pinKey(session.project || '__ungrouped__'), host && pinKey(host), group && `__pinned_group_${group.id}__`];
5138
+ }
5139
+
5140
+ // A card that moves into a collapsed Pinned sub-section would vanish from under the click.
5141
+ function expandPinnedFor(sessionId) {
5142
+ const session = sessions.find((s) => s.id === sessionId);
5143
+ if (!session) return;
5144
+ let changed = false;
5145
+ for (const key of pinnedCollapseKeys(session)) {
5146
+ if (key && collapsedProjectGroups.delete(key)) changed = true;
5147
+ }
5148
+ if (changed) persistCollapsedGroups();
5149
+ }
5150
+
5029
5151
  function isGroupHeader(el) {
5030
5152
  return el.matches(COLLAPSIBLE_HEADER_SELECTOR);
5031
5153
  }
@@ -5088,7 +5210,7 @@ function activateSelectedSession(items) {
5088
5210
  }
5089
5211
  }
5090
5212
 
5091
- function setFocusZone(zone) {
5213
+ function setFocusZone(zone, target) {
5092
5214
  clearKbSelection();
5093
5215
  clearTaskSelection();
5094
5216
 
@@ -5097,8 +5219,11 @@ function setFocusZone(zone) {
5097
5219
  expandSidebar();
5098
5220
  const items = getNavigableItems();
5099
5221
  if (items.length > 0) {
5222
+ const targetIdx = target ? items.indexOf(target) : -1;
5100
5223
  const activeIdx = items.findIndex((el) => el.classList.contains('active'));
5101
- if (activeIdx >= 0) {
5224
+ if (targetIdx >= 0) {
5225
+ selectSessionByIndex(targetIdx, items);
5226
+ } else if (activeIdx >= 0) {
5102
5227
  selectSessionByIndex(activeIdx);
5103
5228
  } else if (selectedSessionKbId) {
5104
5229
  const restoredIdx = items.findIndex((el) => getKbId(el) === selectedSessionKbId);
@@ -5129,6 +5254,18 @@ function setFocusZone(zone) {
5129
5254
  }
5130
5255
  }
5131
5256
 
5257
+ // A pointer click puts the keyboard cursor on the row, unless a task card or the terminal holds it.
5258
+ // Enter's el.click() arrives with detail 0 and already has the cursor.
5259
+ sessionsList.addEventListener('click', (e) => {
5260
+ if (e.detail === 0 || e.target.closest(SG_ACTION_SELECTOR)) return;
5261
+ const el = e.target.closest(
5262
+ '.session-item, .project-group-header, .session-group-header, .pinned-sub-header, .sg-section-toggle',
5263
+ );
5264
+ if (!el) return;
5265
+ const taskInUse = selectedTaskId && selectedSessionId === (el.dataset.sessionId || currentSessionId);
5266
+ if (focusZone === 'sidebar' || (!taskInUse && !terminalPaneFocused())) setFocusZone('sidebar', el);
5267
+ });
5268
+
5132
5269
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5133
5270
  function getAvailableTasksOptions(currentTaskId = null) {
5134
5271
  const pending = currentTasks.filter((t) => t.status === 'pending' && t.id !== currentTaskId);
@@ -5198,7 +5335,8 @@ async function showTaskDetail(taskId, sessionId = null) {
5198
5335
  pending: '<span class="detail-status pending"><span class="dot"></span>Pending</span>',
5199
5336
  };
5200
5337
 
5201
- const isBlocked = task.blockedBy && task.blockedBy.length > 0;
5338
+ const blockers = openBlockers(task);
5339
+ const isBlocked = blockers.length > 0;
5202
5340
  const actualSessionId = task.sessionId || sessionId || currentSessionId;
5203
5341
 
5204
5342
  detailContent.innerHTML = `
@@ -5231,12 +5369,12 @@ async function showTaskDetail(taskId, sessionId = null) {
5231
5369
  }
5232
5370
 
5233
5371
  ${
5234
- task.blockedBy && task.blockedBy.length > 0
5372
+ isBlocked
5235
5373
  ? `
5236
5374
  <div class="detail-section">
5237
5375
  <div class="detail-label">Blocked By</div>
5238
5376
  <div class="detail-deps">
5239
- <div class="detail-box blocked"><strong>Blocked by:</strong> ${task.blockedBy.map((id) => `#${id}`).join(', ')}</div>
5377
+ <div class="detail-box blocked"><strong>Blocked by:</strong> ${blockers.map((id) => `#${id}`).join(', ')}</div>
5240
5378
  </div>
5241
5379
  </div>`
5242
5380
  : ''
@@ -5495,147 +5633,219 @@ async function confirmDelete() {
5495
5633
  //#endregion
5496
5634
 
5497
5635
  //#region HELP
5498
- // Each entry pairs a left and a right group onto the same grid rows, so their
5499
- // headings sit level and the shorter group just leaves empty rows.
5500
- // `combo` joins the keys with a plus (a chord) instead of listing them as
5501
- // alternatives; `hub` marks rows that only work inside Claude Code Hub.
5502
- const SHORTCUT_PAIRS = [
5503
- [
5504
- {
5505
- title: 'Navigate',
5506
- rows: [
5507
- { keys: ['↓'], label: 'Next item' },
5508
- { keys: ['↑'], label: 'Previous item' },
5509
- { keys: ['←'], label: 'Left column / collapse group' },
5510
- { keys: ['→'], label: 'Right column / expand group' },
5511
- { keys: ['Tab'], label: 'Switch sidebar ↔ board' },
5512
- { keys: ['Enter', 'Space'], label: 'Open selected item' },
5513
- ],
5514
- },
5515
- {
5516
- title: 'Session',
5517
- rows: [
5518
- { keys: ['P'], label: 'Open plan' },
5519
- { keys: ['Ctrl', 'Shift', 'P'], combo: true, label: 'Session picker' },
5520
- { keys: ['I'], label: 'Session info' },
5521
- { keys: ['.'], label: 'Pin / unpin' },
5522
- { keys: ['>'], label: 'Toggle sticky' },
5523
- { keys: ['Shift', 'F10'], combo: true, label: 'Move to group (or Menu key)' },
5524
- { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session' },
5525
- { keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
5526
- { keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
5527
- { keys: ['↑', '↓'], label: 'Previous / next message in detail' },
5528
- { keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt' },
5529
- ],
5530
- },
5531
- ],
5532
- [
5533
- {
5534
- title: 'Board',
5535
- rows: [
5536
- { keys: ['Enter'], label: 'Toggle task detail panel' },
5537
- { keys: ['D'], label: 'Delete selected task' },
5538
- { keys: ['N'], label: 'Toggle scratchpad (in the sidebar: the item under the cursor)' },
5539
- { keys: ['R'], label: 'Refresh data' },
5540
- { keys: ['Esc'], label: 'Close panel / clear selection' },
5541
- ],
5542
- },
5543
- {
5544
- title: 'View',
5545
- rows: [
5546
- { keys: ['['], label: 'Toggle sidebar' },
5547
- { keys: ['T'], label: 'Toggle theme' },
5548
- { keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
5549
- { keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
5550
- { keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
5551
- { keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
5552
- { keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
5553
- { keys: ['?'], label: 'Show this help' },
5554
- ],
5555
- },
5556
- ],
5557
- [
5558
- {
5559
- title: 'Copy',
5560
- rows: [
5561
- { keys: ['Shift', 'C'], combo: true, label: 'Session id' },
5562
- { keys: ['Ctrl', 'Shift', 'C'], combo: true, label: 'Resume command (claude -r <id>)' },
5563
- ],
5564
- },
5565
- {
5566
- title: 'Hub',
5567
- hub: true,
5568
- rows: [
5569
- { keys: ['M'], label: 'Jump to marketplace' },
5570
- { keys: ['$'], label: 'Jump to cost' },
5571
- { keys: ['Ctrl', 'M'], combo: true, label: 'Jump to memory' },
5572
- { keys: ['Ctrl', 'Alt', '←/→'], combo: true, label: 'Previous / next hub app' },
5573
- { keys: ['Alt', '1…9'], combo: true, label: 'Jump to hub app by number' },
5574
- { keys: ['Ctrl', 'Alt', 'P'], combo: true, label: 'Project picker' },
5575
- ],
5576
- },
5577
- ],
5578
- [
5579
- {
5580
- title: 'Terminal',
5581
- rows: [
5582
- { keys: ['Ctrl', '`'], combo: true, label: 'Show / hide terminal' },
5583
- { keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
5584
- { keys: ['Alt', 'Shift', '`'], combo: true, label: 'End and close terminal' },
5585
- { keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
5586
- { keys: ['Ctrl', '+/−/0'], combo: true, label: 'Terminal text size' },
5587
- ],
5588
- },
5589
- {
5590
- title: 'Sessions',
5591
- rows: [
5592
- { keys: ['Ctrl', 'Alt', 'N'], combo: true, label: 'New session' },
5593
- { keys: ['Ctrl', 'Alt', 'R'], combo: true, label: 'Resume session (claude -r)' },
5594
- { keys: ['Ctrl', 'Alt', 'S'], combo: true, label: 'Swap to previous session' },
5595
- ],
5596
- },
5597
- ],
5636
+ // Each tab shows its groups two to a row; a pair shares grid rows, so the two headings sit level and
5637
+ // the shorter group leaves empty rows. `combo` joins the keys with a plus (a chord) instead of
5638
+ // listing them as alternatives. The `hub` tab only works inside Claude Code Hub.
5639
+ const SHORTCUT_TABS = [
5640
+ {
5641
+ title: 'Board',
5642
+ groups: [
5643
+ {
5644
+ title: 'Navigate',
5645
+ rows: [
5646
+ { keys: ['↓'], label: 'Next item' },
5647
+ { keys: ['↑'], label: 'Previous item' },
5648
+ { keys: ['←'], label: 'Left column / collapse group' },
5649
+ { keys: ['→'], label: 'Right column / expand group' },
5650
+ { keys: ['Tab'], label: 'Switch sidebar ↔ board' },
5651
+ { keys: ['Enter', 'Space'], label: 'Open selected item' },
5652
+ ],
5653
+ },
5654
+ {
5655
+ title: 'Board',
5656
+ rows: [
5657
+ { keys: ['Enter'], label: 'Toggle task detail panel' },
5658
+ { keys: ['D'], label: 'Delete selected task' },
5659
+ { keys: ['N'], label: 'Toggle scratchpad (in the sidebar: the item under the cursor)' },
5660
+ { keys: ['R'], label: 'Refresh data' },
5661
+ { keys: ['Esc'], label: 'Close panel / clear selection' },
5662
+ ],
5663
+ },
5664
+ {
5665
+ title: 'View',
5666
+ rows: [
5667
+ { keys: ['['], label: 'Toggle sidebar' },
5668
+ { keys: ['T'], label: 'Toggle theme' },
5669
+ { keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
5670
+ { keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
5671
+ { keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
5672
+ { keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
5673
+ { keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
5674
+ { keys: ['?'], label: 'Show this help' },
5675
+ ],
5676
+ },
5677
+ {
5678
+ title: 'Copy',
5679
+ rows: [
5680
+ { keys: ['Shift', 'C'], combo: true, label: 'Session id' },
5681
+ { keys: ['Ctrl', 'Shift', 'C'], combo: true, label: 'Session name (SendMessage address)' },
5682
+ ],
5683
+ },
5684
+ ],
5685
+ },
5686
+ {
5687
+ title: 'Session',
5688
+ groups: [
5689
+ {
5690
+ title: 'Session',
5691
+ rows: [
5692
+ { keys: ['P'], label: 'Open plan' },
5693
+ { keys: ['Ctrl', 'Shift', 'P'], combo: true, label: 'Session picker' },
5694
+ { keys: ['Shift', 'P'], combo: true, label: 'Project picker (this board)' },
5695
+ { keys: ['I'], label: 'Session info' },
5696
+ { keys: ['.'], label: 'Pin / unpin' },
5697
+ { keys: ['>'], label: 'Toggle sticky' },
5698
+ { keys: ['Shift', 'F10'], combo: true, label: 'Move to group (or Menu key)' },
5699
+ { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session' },
5700
+ { keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
5701
+ { keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
5702
+ { keys: ['↑', '↓'], label: 'Previous / next message in detail' },
5703
+ { keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt' },
5704
+ ],
5705
+ },
5706
+ {
5707
+ title: 'Sessions',
5708
+ rows: [
5709
+ { keys: ['Ctrl', 'Alt', 'N'], combo: true, label: 'New session' },
5710
+ { keys: ['Ctrl', 'Alt', 'R'], combo: true, label: 'Resume session (claude -r)' },
5711
+ { keys: ['Ctrl', 'Alt', 'S'], combo: true, label: 'Swap to previous session' },
5712
+ ],
5713
+ },
5714
+ ],
5715
+ },
5716
+ {
5717
+ title: 'Terminal',
5718
+ groups: [
5719
+ {
5720
+ title: 'Terminal',
5721
+ rows: [
5722
+ { keys: ['Ctrl', '`'], combo: true, label: 'Show / hide terminal' },
5723
+ { keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
5724
+ { keys: ['Alt', 'Shift', '`'], combo: true, label: 'End and close terminal' },
5725
+ { keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
5726
+ { keys: ['Ctrl', '+/−/0'], combo: true, label: 'Terminal text size' },
5727
+ ],
5728
+ },
5729
+ ],
5730
+ },
5731
+ {
5732
+ title: 'Hub',
5733
+ hub: true,
5734
+ groups: [
5735
+ {
5736
+ title: 'Hub',
5737
+ rows: [
5738
+ { keys: ['M'], label: 'Jump to marketplace' },
5739
+ { keys: ['$'], label: 'Jump to cost' },
5740
+ { keys: ['Ctrl', 'M'], combo: true, label: 'Jump to memory' },
5741
+ { keys: ['Ctrl', 'Alt', '←/→'], combo: true, label: 'Previous / next hub app' },
5742
+ { keys: ['Alt', '1…9'], combo: true, label: 'Jump to hub app by number' },
5743
+ { keys: ['Ctrl', 'Alt', 'P'], combo: true, label: 'Project picker' },
5744
+ { keys: ['Ctrl', 'Alt', 'W'], combo: true, label: 'Config dir picker' },
5745
+ { keys: ['Ctrl', 'Alt', 'A'], combo: true, label: 'App launcher' },
5746
+ ],
5747
+ },
5748
+ ],
5749
+ },
5598
5750
  ];
5599
5751
 
5600
- const EMPTY_GROUP = { title: '', rows: [] };
5601
-
5602
5752
  // Interleaves each pair's rows left-then-right so CSS grid auto-placement lands
5603
5753
  // them on shared row tracks (see .shortcuts in style.css).
5604
- function buildHelpShortcuts() {
5754
+ function buildHelpShortcuts(groups) {
5605
5755
  const cells = [];
5606
- SHORTCUT_PAIRS.forEach(([left, right = EMPTY_GROUP], pair) => {
5756
+ for (let pair = 0; pair < groups.length; pair += 2) {
5607
5757
  const first = pair === 0 ? ' sc-first' : '';
5608
5758
  const sides = [
5609
- [left, 'sc-l'],
5610
- [right, 'sc-r'],
5611
- ];
5612
- const head = (g, side) =>
5613
- g.title ? `<div class="${escapeHtml(`sc-group ${side}${first}${g.hub ? ' sc-hub' : ''}`)}">${g.title}</div>` : '';
5614
- cells.push(sides.map(([g, side]) => head(g, side)).join(''));
5615
- for (let i = 0; i < Math.max(left.rows.length, right.rows.length); i++) {
5759
+ [groups[pair], 'sc-l'],
5760
+ [groups[pair + 1], 'sc-r'],
5761
+ ].filter(([g]) => g);
5762
+ for (const [g, side] of sides)
5763
+ cells.push(`<div class="${escapeHtml(`sc-group ${side}${first}`)}">${escapeHtml(g.title)}</div>`);
5764
+ for (let i = 0; i < Math.max(...sides.map(([g]) => g.rows.length)); i++) {
5616
5765
  for (const [group, side] of sides) {
5617
5766
  const row = group.rows[i];
5618
5767
  if (!row) continue;
5619
5768
  const sep = row.combo ? '<span class="sc-plus">+</span>' : '<span class="sc-or">/</span>';
5620
5769
  const keys = row.keys.map((k) => `<kbd>${escapeHtml(k)}</kbd>`).join(sep);
5621
- const cls = side + (group.hub ? ' sc-hub' : '');
5622
- cells.push(
5623
- `<dt class="${escapeHtml(cls)}">${keys}</dt><dd class="${escapeHtml(cls)}">${escapeHtml(row.label)}</dd>`,
5624
- );
5770
+ const label = escapeHtml(row.label);
5771
+ cells.push(`<dt class="${escapeHtml(side)}">${keys}</dt><dd class="${escapeHtml(side)}">${label}</dd>`);
5625
5772
  }
5626
5773
  }
5627
- });
5774
+ }
5628
5775
  return cells.join('');
5629
5776
  }
5630
5777
 
5778
+ let helpTab = 0;
5779
+ const helpFilter = document.getElementById('help-filter');
5780
+
5781
+ // Hub keys do nothing outside Claude Code Hub, so the standalone board drops that tab.
5782
+ const helpTabs = () => SHORTCUT_TABS.filter((t) => hub.inHub || !t.hub);
5783
+
5784
+ // The matching rows of every tab, in their groups.
5785
+ function filteredShortcutGroups(query) {
5786
+ return helpTabs()
5787
+ .flatMap((t) => t.groups)
5788
+ .map((g) => ({ title: g.title, rows: g.rows.filter((r) => fuzzyMatch(`${r.label} ${r.keys.join(' ')}`, query)) }))
5789
+ .filter((g) => g.rows.length);
5790
+ }
5791
+
5792
+ function renderHelp() {
5793
+ const tabs = helpTabs();
5794
+ helpTab = Math.min(helpTab, tabs.length - 1);
5795
+ const query = helpFilter.value.trim();
5796
+ document.getElementById('help-tabs').innerHTML = tabs
5797
+ .map((t, i) => {
5798
+ const on = !query && i === helpTab;
5799
+ return `<button type="button" role="tab" class="help-tab${on ? ' active' : ''}" aria-selected="${escapeHtml(String(on))}" data-idx="${i}">${escapeHtml(t.title)}</button>`;
5800
+ })
5801
+ .join('');
5802
+ const groups = query ? filteredShortcutGroups(query) : tabs[helpTab].groups;
5803
+ document.getElementById('help-shortcuts').innerHTML = groups.length
5804
+ ? buildHelpShortcuts(groups)
5805
+ : `<div class="sc-empty">No shortcut matches "${escapeHtml(query)}"</div>`;
5806
+ }
5807
+
5808
+ function clearHelpFilter() {
5809
+ helpFilter.value = '';
5810
+ renderHelp();
5811
+ }
5812
+
5813
+ function selectHelpTab(idx) {
5814
+ helpTab = idx;
5815
+ clearHelpFilter();
5816
+ }
5817
+
5631
5818
  function showHelpModal() {
5632
- const modal = document.getElementById('help-modal');
5633
- const list = document.getElementById('help-shortcuts');
5634
- if (!list.childElementCount) list.innerHTML = buildHelpShortcuts();
5635
- list.classList.toggle('sc-standalone', !window.__HUB__?.enabled);
5636
- modal.classList.add('visible');
5819
+ clearHelpFilter();
5820
+ document.getElementById('help-modal').classList.add('visible');
5821
+ helpFilter.focus();
5637
5822
  }
5638
5823
 
5824
+ document.getElementById('help-tabs').addEventListener('click', (e) => {
5825
+ const tab = e.target.closest('[data-idx]');
5826
+ if (!tab) return;
5827
+ selectHelpTab(Number(tab.dataset.idx));
5828
+ helpFilter.focus();
5829
+ });
5830
+
5831
+ helpFilter.addEventListener('input', renderHelp);
5832
+
5833
+ // The page's key handler skips text fields, so the filter handles its own Esc and tab keys.
5834
+ helpFilter.addEventListener('keydown', (e) => {
5835
+ if (e.key === 'Escape') {
5836
+ e.preventDefault();
5837
+ e.stopPropagation();
5838
+ if (helpFilter.value) clearHelpFilter();
5839
+ else closeHelpModal();
5840
+ return;
5841
+ }
5842
+ const step = matchKey(e, 'ArrowLeft') ? -1 : matchKey(e, 'ArrowRight') ? 1 : 0;
5843
+ if (!step || helpFilter.value) return;
5844
+ e.preventDefault();
5845
+ const n = helpTabs().length;
5846
+ selectHelpTab((helpTab + step + n) % n);
5847
+ });
5848
+
5639
5849
  function closeHelpModal() {
5640
5850
  const modal = document.getElementById('help-modal');
5641
5851
  modal.classList.remove('visible');
@@ -6275,6 +6485,7 @@ const MODAL_CLOSERS = {
6275
6485
  'agent-modal': () => closeAgentModal(),
6276
6486
  'help-modal': () => closeHelpModal(),
6277
6487
  'session-picker-modal': () => closeSessionPicker(),
6488
+ 'project-picker-modal': () => closeProjectPicker(),
6278
6489
  'terminal-manager-modal': () => closeTerminalManager(),
6279
6490
  'new-session-modal': () => closeNewSession(),
6280
6491
  };
@@ -6395,6 +6606,11 @@ document.addEventListener('keydown', (e) => {
6395
6606
  toggleMessagePanel();
6396
6607
  return;
6397
6608
  }
6609
+ if (e.code === 'KeyP' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
6610
+ e.preventDefault();
6611
+ openProjectPicker();
6612
+ return;
6613
+ }
6398
6614
  if (e.code === 'KeyM' && e.shiftKey) {
6399
6615
  e.preventDefault();
6400
6616
  const msgDetailModal = document.getElementById('msg-detail-modal');
@@ -6562,19 +6778,19 @@ document.addEventListener('keydown', (e) => {
6562
6778
  }
6563
6779
  if (e.key === '$' && !e.ctrlKey && !e.altKey && !e.metaKey) {
6564
6780
  e.preventDefault();
6565
- hubNavigate('cost', contextSid ? `?view=detail&session=${encodeURIComponent(contextSid)}` : undefined);
6781
+ openCost(contextSid);
6566
6782
  return;
6567
6783
  }
6568
6784
  if (matchKey(e, 'KeyM')) {
6569
6785
  e.preventDefault();
6570
6786
  const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
6571
- hubNavigate('marketplace', mSession?.project ? `?project=${encodeURIComponent(mSession.project)}` : undefined);
6787
+ openMarketplace(mSession?.project);
6572
6788
  return;
6573
6789
  }
6574
6790
  if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'm') {
6575
6791
  e.preventDefault();
6576
6792
  const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
6577
- hubNavigate('memory', mSession?.project ? `?project=${encodeURIComponent(mSession.project)}` : undefined);
6793
+ openMemory(mSession?.project);
6578
6794
  return;
6579
6795
  }
6580
6796
  if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
@@ -6608,11 +6824,17 @@ document.addEventListener('keydown', (e) => {
6608
6824
  showToast('No session selected');
6609
6825
  return;
6610
6826
  }
6611
- const text = e.ctrlKey ? `claude -r ${contextSid}` : contextSid;
6612
- const label = e.ctrlKey ? 'resume command' : 'session id';
6827
+ const peerName = e.ctrlKey ? sessions.find((s) => s.id === contextSid)?.peerName : null;
6828
+ const [text, label] = e.ctrlKey
6829
+ ? [peerName && sendMessageAddress(peerName), 'session name']
6830
+ : [contextSid, 'session id'];
6831
+ if (!text) {
6832
+ showToast('No live session name: the session is not running');
6833
+ return;
6834
+ }
6613
6835
  navigator.clipboard
6614
6836
  .writeText(text)
6615
- .then(() => showToast(`Copied ${label}: ${contextSid.slice(0, 8)}`, 'success'))
6837
+ .then(() => showToast(`Copied ${label}: ${e.ctrlKey ? text : text.slice(0, 8)}`, 'success'))
6616
6838
  .catch(() => showToast(`Failed to copy ${label}`));
6617
6839
  return;
6618
6840
  }
@@ -6939,6 +7161,7 @@ function handleSessionOpenEvent(data) {
6939
7161
  stickySessionIds.add(id);
6940
7162
  }
6941
7163
  setSessionDismissed(id, false);
7164
+ cliOpenedId = id;
6942
7165
  fetchTasks(id);
6943
7166
  }
6944
7167
 
@@ -6963,7 +7186,7 @@ function handleDocumentLinkEvent(data) {
6963
7186
  setSessionDocLink(sessionId, filePath, unlink);
6964
7187
  const msg = `${unlink ? 'Unlinked' : 'Linked'} ${linkedDocLabel(filePath)}`;
6965
7188
  // Every tab gets the event; only the one on screen offers to open the URL.
6966
- if (open && isOnScreen()) showToast(msg, 'info', { label: 'Open', onClick: () => openExternal(filePath) });
7189
+ if (open && isOnScreen()) showToast(msg, 'info', { label: 'Open', onClick: () => hub.openExternal(filePath) });
6967
7190
  else showToast(msg);
6968
7191
  }
6969
7192
 
@@ -7090,7 +7313,7 @@ function linkedDocLabel(p) {
7090
7313
  function openLinkedDoc(p, baseDir) {
7091
7314
  const opener = linkedDocOpener(p);
7092
7315
  if (opener === 'url') {
7093
- openExternal(p);
7316
+ hub.openExternal(p);
7094
7317
  return;
7095
7318
  }
7096
7319
  if (opener === 'preview') {
@@ -7606,7 +7829,7 @@ function setupEventSource() {
7606
7829
  if (data.type === 'dispatch-update') onDispatchUpdate();
7607
7830
 
7608
7831
  if (data.type === 'agent-update') {
7609
- pendingAgentSessionIds.add(data.sessionId);
7832
+ if (!data.unreadOnly) pendingAgentSessionIds.add(data.sessionId);
7610
7833
  clearTimeout(agentRefreshTimer);
7611
7834
  agentRefreshTimer = setTimeout(() => {
7612
7835
  if (skipOffScreen()) return;
@@ -7658,6 +7881,7 @@ function setupEventSource() {
7658
7881
 
7659
7882
  // When cck comes back on screen (tab visible or hub:active), catch up immediately
7660
7883
  async function catchUp() {
7884
+ markOpenSessionRead();
7661
7885
  if (!isOnScreen() || !missedWhileHidden) return;
7662
7886
  missedWhileHidden = false;
7663
7887
  // The full refresh below covers whatever the pending debounces would have fetched.
@@ -7678,12 +7902,11 @@ function setupEventSource() {
7678
7902
  renderAgentFooter();
7679
7903
  }
7680
7904
  document.addEventListener('visibilitychange', catchUp);
7681
- window.addEventListener('message', (e) => {
7682
- if (e.source !== window.parent || e.origin !== hubOrigin() || e.data?.type !== 'hub:active') return;
7683
- hubActive = !!e.data.active;
7905
+ onHubActive = (active) => {
7906
+ hubActive = active;
7684
7907
  catchUp();
7685
7908
  if (hubActive && wantsTerminal()) onTerminalShown();
7686
- });
7909
+ };
7687
7910
 
7688
7911
  // Fallback poll every 30s in case SSE silently drops; skip when not on screen
7689
7912
  setInterval(() => {
@@ -8099,7 +8322,7 @@ function formatDate(dateStr) {
8099
8322
  const now = new Date();
8100
8323
  const diff = now - date;
8101
8324
 
8102
- if (diff < 60000) return 'just now';
8325
+ if (diff < JUST_NOW_MS) return 'just now';
8103
8326
  if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
8104
8327
  if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
8105
8328
  return date.toLocaleDateString();
@@ -8680,12 +8903,10 @@ function renderZenState() {
8680
8903
  : 'Zen mode: current session only (Ctrl+Shift+Z)';
8681
8904
  }
8682
8905
 
8683
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
8684
8906
  function filterBySessions(value) {
8685
8907
  if (value === sessionFilter) return;
8686
- sessionFilter = value;
8908
+ setSessionFilter(value);
8687
8909
  resetSessionPage();
8688
- if (value !== 'active') activityFilter.clear();
8689
8910
  renderFilterState();
8690
8911
  updateUrl();
8691
8912
  // Instant feedback from cached data, then refetch — the cached list was fetched
@@ -8696,6 +8917,11 @@ function filterBySessions(value) {
8696
8917
  fetchSessions(false);
8697
8918
  }
8698
8919
 
8920
+ function setSessionFilter(value) {
8921
+ sessionFilter = value;
8922
+ if (value !== 'active') activityFilter.clear();
8923
+ }
8924
+
8699
8925
  function projectFilterText() {
8700
8926
  return filterProject && filterProject !== '__recent__' ? filterProject : '';
8701
8927
  }
@@ -8973,15 +9199,17 @@ const COLOR_THEMES = [
8973
9199
  ['synthwave', "Synthwave '84"],
8974
9200
  ];
8975
9201
 
8976
- function buildThemeMenu() {
9202
+ function buildThemeMenu(themes = COLOR_THEMES) {
8977
9203
  const menu = document.getElementById('themeMenu');
8978
- menu.innerHTML = COLOR_THEMES.map(
8979
- ([id, label]) =>
8980
- `<button type="button" class="theme-menu-item theme-swatch-${id}" data-theme-id="${id}"
9204
+ menu.innerHTML = themes
9205
+ .map(
9206
+ ([id, label]) =>
9207
+ `<button type="button" class="theme-menu-item theme-swatch-${id}" data-theme-id="${id}"
8981
9208
  onclick="event.stopPropagation(); setColorTheme('${id}'); toggleThemeMenu()">
8982
- <span class="theme-swatch theme-swatch-${id}"><i class="sw-bg"></i><i class="sw-accent"></i><i class="sw-ink"></i></span>${label}
9209
+ <span class="theme-swatch theme-swatch-${id}"><i class="sw-bg"></i><i class="sw-accent"></i><i class="sw-ink"></i></span>${escapeHtml(label)}
8983
9210
  </button>`,
8984
- ).join('');
9211
+ )
9212
+ .join('');
8985
9213
  }
8986
9214
 
8987
9215
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -9241,10 +9469,13 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
9241
9469
  let html = '';
9242
9470
 
9243
9471
  // Session & project details as compact key-value rows
9244
- // Each row: [label, value, { openPath?, abbrev? }] — `value` is authoritative
9245
- // (tooltip + copy); `abbrev` only replaces the rendered text.
9472
+ // Each row: [label, value, { openPath?, abbrev?, copy? }] — `value` is authoritative
9473
+ // (tooltip + copy); `abbrev` only replaces the rendered text, `copy` the copied text.
9246
9474
  const infoRows = [];
9247
9475
  infoRows.push(['Session', session.id, { openClaudeDir: true, openFile: session.jsonlPath }]);
9476
+ if (session.peerName) {
9477
+ infoRows.push(['Peer name', session.peerName, { copy: sendMessageAddress(session.peerName) }]);
9478
+ }
9248
9479
  if (parentInfo?.parentSessionId) {
9249
9480
  infoRows.push([
9250
9481
  parentInfo.relation === 'compact' ? 'Continued from' : 'Forked from',
@@ -9298,7 +9529,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
9298
9529
  } else {
9299
9530
  html += `<span style="${plainStyle}" title="${escapeHtml(value)}">${escapeHtml(opts?.abbrev || value)}</span>`;
9300
9531
  }
9301
- const copyBtn = `<button onclick="copyWithFeedback('${escAttrJs(value)}', this)" title="Copy">${ICON_COPY}</button>`;
9532
+ const copyBtn = `<button onclick="copyWithFeedback('${escAttrJs(opts?.copy || value)}', this)" title="Copy">${ICON_COPY}</button>`;
9302
9533
  let openBtn = '';
9303
9534
  if (opts?.openClaudeDir || opts?.openPath) {
9304
9535
  const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath);
@@ -9393,12 +9624,12 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
9393
9624
  updateStickyBtnState();
9394
9625
  updateDismissBtnState();
9395
9626
  const costBtn = document.getElementById('session-info-cost-btn');
9396
- if (costBtn) costBtn.style.display = window.__HUB__?.enabled || appConfig.costUrl ? '' : 'none';
9627
+ if (costBtn) costBtn.style.display = hub.can('session.cost') ? '' : 'none';
9397
9628
  const mkBtn = document.getElementById('session-info-marketplace-btn');
9398
9629
  const memBtn = document.getElementById('session-info-memory-btn');
9399
9630
  const proj = session.project;
9400
- if (mkBtn) mkBtn.style.display = proj && (window.__HUB__?.enabled || appConfig.marketplaceUrl) ? '' : 'none';
9401
- if (memBtn) memBtn.style.display = proj && (window.__HUB__?.enabled || appConfig.memoryUrl) ? '' : 'none';
9631
+ if (mkBtn) mkBtn.style.display = proj && hub.can('project.plugins') ? '' : 'none';
9632
+ if (memBtn) memBtn.style.display = proj && hub.can('project.memory') ? '' : 'none';
9402
9633
  modal.classList.add('visible');
9403
9634
 
9404
9635
  if (alreadyVisible) return; // re-render during deferred hydration — key handler already attached
@@ -9512,8 +9743,12 @@ function closeSessionPicker({ refocus = true } = {}) {
9512
9743
  if (refocus && spFromTerminal && wantsTerminal()) focusTerminalPane();
9513
9744
  }
9514
9745
 
9746
+ function isSessionPickerOpen() {
9747
+ return document.getElementById('session-picker-modal').classList.contains('visible');
9748
+ }
9749
+
9515
9750
  function toggleSessionPicker() {
9516
- if (document.getElementById('session-picker-modal').classList.contains('visible')) closeSessionPicker();
9751
+ if (isSessionPickerOpen()) closeSessionPicker();
9517
9752
  else openSessionPicker();
9518
9753
  }
9519
9754
 
@@ -9580,17 +9815,35 @@ function spMatches(session, query) {
9580
9815
  }
9581
9816
 
9582
9817
  // Spelled out rather than interpolated so the class never comes from a variable.
9583
- const SP_DOTS = {
9584
- waiting: '<span class="activity-dot waiting"></span>',
9585
- live: '<span class="activity-dot live"></span>',
9586
- active: '<span class="activity-dot active"></span>',
9587
- idle: '<span class="activity-dot"></span>',
9818
+ const SP_STATES = {
9819
+ waiting: '<span class="sp-state waiting">',
9820
+ live: '<span class="sp-state live">',
9821
+ active: '<span class="sp-state active">',
9822
+ justFinished: '<span class="sp-state just-finished">',
9823
+ recent: '<span class="sp-state recent">',
9824
+ idle: '<span class="sp-state idle">',
9588
9825
  };
9589
9826
 
9827
+ const SP_DOT = '<span class="activity-dot"></span>';
9828
+ const SP_TERMINAL = `<span class="sp-terminal" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>`;
9829
+
9830
+ function spState(session) {
9831
+ if (isWaitingSession(session)) return 'waiting';
9832
+ if (isSessionLive(session)) return 'live';
9833
+ if (isActiveSession(session)) return 'active';
9834
+ if (isJustFinished(session)) return 'justFinished';
9835
+ return session.hasRecentActivity || session.inProgress > 0 ? 'recent' : 'idle';
9836
+ }
9837
+
9590
9838
  function spDotHtml(session) {
9591
- if (isWaitingSession(session)) return SP_DOTS.waiting;
9592
- if (isSessionLive(session)) return SP_DOTS.live;
9593
- return isActiveSession(session) ? SP_DOTS.active : SP_DOTS.idle;
9839
+ return `${SP_STATES[spState(session)]}${runningTerminals.has(session.id) ? SP_TERMINAL : SP_DOT}</span>`;
9840
+ }
9841
+
9842
+ function spPinHtml(session) {
9843
+ const state = getSessionPinState(session.id);
9844
+ if (state === 'sticky') return `<span class="sp-pin sticky" title="Sticky">${SESSION_STAR_SVG}</span>`;
9845
+ if (state === 'pinned') return `<span class="sp-pin pinned" title="Pinned">${SESSION_PIN_SVG}</span>`;
9846
+ return '<span class="sp-pin"></span>';
9594
9847
  }
9595
9848
 
9596
9849
  function renderSessionPicker() {
@@ -9626,6 +9879,7 @@ function renderSessionPicker() {
9626
9879
  ${s.gitBranch ? `<span class="sp-branch">${escapeHtml(s.gitBranch)}</span>` : ''}
9627
9880
  <span class="sp-count">${s.completed}/${s.taskCount}</span>
9628
9881
  <span class="sp-time">${formatDate(s.modifiedAt)}</span>
9882
+ ${spPinHtml(s)}
9629
9883
  </button>`;
9630
9884
  })
9631
9885
  .join('');
@@ -9685,6 +9939,122 @@ function initSessionPicker() {
9685
9939
  }
9686
9940
  //#endregion
9687
9941
 
9942
+ //#region PROJECT_PICKER
9943
+ // Sets only this board's project filter; a later hub project.changed replaces it.
9944
+ let ppRows = [];
9945
+ let ppIdx = 0;
9946
+ let ppSource = null;
9947
+ let ppRecentOnly = false;
9948
+
9949
+ async function openProjectPicker() {
9950
+ const input = document.getElementById('project-picker-input');
9951
+ input.value = '';
9952
+ renderProjectPicker();
9953
+ document.getElementById('project-picker-modal').classList.add('visible');
9954
+ input.focus();
9955
+ await refreshProjectList();
9956
+ ppSource = (projectsCache || [])
9957
+ .filter((p) => !p.temp)
9958
+ .map((p) => ({ ...p, norm: normalizeProjectPath(p.path) }))
9959
+ .sort((a, b) => (b.modifiedAt || '').localeCompare(a.modifiedAt || ''));
9960
+ renderProjectPicker();
9961
+ }
9962
+
9963
+ function ppToggleRecentOnly() {
9964
+ ppRecentOnly = !ppRecentOnly;
9965
+ document.getElementById('pp-recent-chip').setAttribute('aria-pressed', String(ppRecentOnly));
9966
+ renderProjectPicker();
9967
+ document.getElementById('project-picker-input').focus();
9968
+ }
9969
+
9970
+ // One fetch for both filters: two in a row could land out of order.
9971
+ function ppSetFilters(project, session) {
9972
+ closeProjectPicker();
9973
+ if (project === filterProject) return filterBySessions(session);
9974
+ setSessionFilter(session);
9975
+ filterByProject(project);
9976
+ }
9977
+
9978
+ function closeProjectPicker() {
9979
+ hideModalOverlay('project-picker-modal');
9980
+ }
9981
+
9982
+ function renderProjectPicker() {
9983
+ const list = document.getElementById('project-picker-list');
9984
+ const query = document.getElementById('project-picker-input').value.trim();
9985
+ const source = ppSource || [];
9986
+ const pool = ppRecentOnly ? source.filter((p) => recentProjects.has(p.path)) : source;
9987
+ ppRows = query ? pool.filter((p) => fuzzyMatch(p.path, query)) : pool;
9988
+ if (!ppRows.length) {
9989
+ ppIdx = -1;
9990
+ const empty = ppRecentOnly ? `No project active in the last ${RECENT_PROJECT_HOURS}h` : 'No project matches';
9991
+ list.innerHTML = `<div class="sp-empty">${ppSource ? empty : 'Loading projects…'}</div>`;
9992
+ return;
9993
+ }
9994
+ const current = filterProject && normalizeProjectPath(filterProject);
9995
+ list.innerHTML = ppRows
9996
+ .map((p, i) => {
9997
+ return `<button class="sp-row${p.norm === current ? ' current' : ''}${recentProjects.has(p.path) ? ' recent' : ''}" data-idx="${i}">
9998
+ <span class="sp-name">${escapeHtml(pathBasename(p.path))}</span>
9999
+ <span class="sp-project">${escapeHtml(p.path)}</span>
10000
+ ${p.modifiedAt ? `<span class="sp-time">${formatDate(p.modifiedAt)}</span>` : ''}
10001
+ </button>`;
10002
+ })
10003
+ .join('');
10004
+ const currentIdx = ppRows.findIndex((p) => p.norm === current);
10005
+ ppSelect(query ? 0 : Math.max(0, currentIdx));
10006
+ }
10007
+
10008
+ function ppSelect(idx) {
10009
+ const list = document.getElementById('project-picker-list');
10010
+ list.children[ppIdx]?.classList.remove('selected');
10011
+ ppIdx = Math.min(Math.max(idx, 0), ppRows.length - 1);
10012
+ const row = list.children[ppIdx];
10013
+ row?.classList.add('selected');
10014
+ row?.scrollIntoView({ block: 'nearest' });
10015
+ }
10016
+
10017
+ function ppApply(idx) {
10018
+ const row = ppRows[idx];
10019
+ if (!row) return;
10020
+ ppSetFilters(row.path, ppRecentOnly ? 'active' : 'all');
10021
+ }
10022
+
10023
+ function initProjectPicker() {
10024
+ const input = document.getElementById('project-picker-input');
10025
+ input.addEventListener('input', renderProjectPicker);
10026
+ input.addEventListener('keydown', (e) => {
10027
+ if (e.key === 'Escape') closeProjectPicker();
10028
+ else if (matchKey(e, 'ArrowDown') || (e.ctrlKey && e.key === 'n')) ppSelect(ppIdx + 1);
10029
+ else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && !e.shiftKey && e.key === 'p')) ppSelect(ppIdx - 1);
10030
+ else if (e.key === 'Enter') ppApply(ppIdx);
10031
+ else if (e.altKey && !e.ctrlKey && !e.metaKey && e.code === 'KeyR') {
10032
+ if (!e.repeat) ppToggleRecentOnly();
10033
+ } else if (e.altKey && !e.ctrlKey && !e.metaKey && e.code === 'KeyC')
10034
+ ppSetFilters(FILTER_DEFAULTS.project, FILTER_DEFAULTS.session);
10035
+ else return;
10036
+ e.preventDefault();
10037
+ e.stopPropagation();
10038
+ });
10039
+ document.getElementById('project-picker-list').addEventListener('click', (e) => {
10040
+ const row = e.target.closest('.sp-row');
10041
+ if (row) ppApply(Number(row.dataset.idx));
10042
+ });
10043
+ // Vimium's Escape only blurs the input; see initSessionPicker.
10044
+ const modal = document.getElementById('project-picker-modal');
10045
+ let pointerDown = false;
10046
+ modal.addEventListener('mousedown', () => {
10047
+ pointerDown = true;
10048
+ });
10049
+ document.addEventListener('mouseup', () => {
10050
+ pointerDown = false;
10051
+ });
10052
+ input.addEventListener('blur', () => {
10053
+ if (!pointerDown && modal.classList.contains('visible') && document.hasFocus()) closeProjectPicker();
10054
+ });
10055
+ }
10056
+ //#endregion
10057
+
9688
10058
  //#region PLAN
9689
10059
  function refreshOpenPlan() {
9690
10060
  if (!_planSessionId || !document.getElementById('plan-modal').classList.contains('visible')) return;
@@ -10062,35 +10432,16 @@ function openFolderInEditor(folder, file) {
10062
10432
  postAndToast('/api/open-folder', body, 'folder');
10063
10433
  }
10064
10434
 
10065
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
10066
10435
  function openCost(sessionId) {
10067
- if (window.__HUB__?.enabled) {
10068
- hubNavigate('cost', `?view=detail&session=${encodeURIComponent(sessionId)}`);
10069
- } else if (appConfig.costUrl) {
10070
- window.open(`${appConfig.costUrl}?view=detail&session=${encodeURIComponent(sessionId)}`, '_blank');
10071
- }
10436
+ hub.invoke('session.cost', sessionId ? { session: sessionId } : {});
10072
10437
  }
10073
10438
 
10074
10439
  function openMarketplace(projectPath) {
10075
- const params = new URLSearchParams({ project: projectPath });
10076
- if (window.__HUB__?.enabled) {
10077
- hubNavigate('marketplace', `?${params}`);
10078
- } else if (appConfig.marketplaceUrl) {
10079
- const url = new URL(appConfig.marketplaceUrl);
10080
- url.search = params.toString();
10081
- window.open(url.toString(), '_blank');
10082
- }
10440
+ hub.invoke('project.plugins', projectPath ? { project: projectPath } : {});
10083
10441
  }
10084
10442
 
10085
10443
  function openMemory(projectPath) {
10086
- const params = new URLSearchParams({ project: projectPath });
10087
- if (window.__HUB__?.enabled) {
10088
- hubNavigate('memory', `?${params}`);
10089
- } else if (appConfig.memoryUrl) {
10090
- const url = new URL(appConfig.memoryUrl);
10091
- url.search = params.toString();
10092
- window.open(url.toString(), '_blank');
10093
- }
10444
+ hub.invoke('project.memory', projectPath ? { project: projectPath } : {});
10094
10445
  }
10095
10446
 
10096
10447
  function openForInfoModalProject(open) {
@@ -10262,10 +10613,9 @@ function filterByOwner(value) {
10262
10613
  // leaving the session view or switching sessions drops only the socket; coming back
10263
10614
  // reattaches and the server replays the screen.
10264
10615
  const TERMINAL_TOKEN_KEY = 'terminal-token';
10616
+ // Every config dir's board is served on the hub's one kanban origin, so sessionStorage is shared.
10617
+ const TERMINAL_FOCUS_KEY = nsKey('terminal-focus');
10265
10618
  const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
10266
- // Ctrl+Alt letters cck keeps instead of forwarding to the hub: New session, Resume session, and
10267
- // Swap to the previous session.
10268
- const CCK_CTRL_ALT_KEYS = new Set(['KeyN', 'KeyR', 'KeyS']);
10269
10619
  const TERMINAL_MODES_KEY = 'terminal-sessions';
10270
10620
  const TERMINAL_FONT_KEY = 'terminal-font-size';
10271
10621
  const TERMINAL_FONT_MIN = 8;
@@ -10322,32 +10672,18 @@ let tokenRefresh = null;
10322
10672
  // The hub mints a new token each run, and this page can outlive a hub restart. Resolves true only
10323
10673
  // for a token that differs from the one that just failed, so a caller retries at most once.
10324
10674
  function refreshTerminalToken() {
10325
- if (!window.__HUB__?.enabled) return Promise.resolve(false);
10326
- tokenRefresh ??= requestTerminalToken().finally(() => {
10327
- tokenRefresh = null;
10328
- });
10329
- return tokenRefresh;
10330
- }
10331
-
10332
- function requestTerminalToken() {
10333
- return new Promise((resolve) => {
10334
- const done = (ok) => {
10335
- window.removeEventListener('message', onMessage);
10336
- clearTimeout(timer);
10337
- resolve(ok);
10338
- };
10339
- const onMessage = (e) => {
10340
- if (e.source !== window.parent || e.origin !== hubOrigin() || e.data?.type !== 'hub:terminalToken') return;
10341
- const token = e.data.token;
10342
- if (typeof token !== 'string' || !TERMINAL_TOKEN_RE.test(token) || token === terminalToken) return done(false);
10675
+ tokenRefresh ??= hub
10676
+ .terminalToken()
10677
+ .then((token) => {
10678
+ if (!TERMINAL_TOKEN_RE.test(token) || token === terminalToken) return false;
10343
10679
  terminalToken = token;
10344
10680
  storeTerminalToken(token);
10345
- done(true);
10346
- };
10347
- const timer = setTimeout(() => done(false), 3000);
10348
- window.addEventListener('message', onMessage);
10349
- hubPost({ type: 'hub:terminalToken' });
10350
- });
10681
+ return true;
10682
+ })
10683
+ .finally(() => {
10684
+ tokenRefresh = null;
10685
+ });
10686
+ return tokenRefresh;
10351
10687
  }
10352
10688
 
10353
10689
  function terminalAvailable() {
@@ -10384,6 +10720,11 @@ function terminalPaneFocused() {
10384
10720
 
10385
10721
  const TEXT_FIELD_SELECTOR = 'input, textarea, select, [contenteditable]';
10386
10722
 
10723
+ function inPageField(e) {
10724
+ const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
10725
+ return !!field && !document.getElementById('terminal-pane').contains(field);
10726
+ }
10727
+
10387
10728
  function terminalShortcut(e) {
10388
10729
  const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
10389
10730
  const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
@@ -10393,13 +10734,11 @@ function terminalShortcut(e) {
10393
10734
  return () => adjustTerminalFontSize(Math.sign(zoom));
10394
10735
  }
10395
10736
  if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
10737
+ if (matchKey(e, 'KeyR') && terminalConnectSlow() && wantsTerminal() && !inPageField(e)) return reconnectTerminal;
10396
10738
  // Cancelling the default also stops Chrome's system print dialog, which this page has no use for.
10397
10739
  if (ctrlShift && e.code === 'KeyP') return toggleSessionPicker;
10398
10740
  // Ctrl+Shift+Z is redo in a text field, so only the terminal's own textarea gives it up.
10399
- if (ctrlShift && e.code === 'KeyZ') {
10400
- const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
10401
- return field && !document.getElementById('terminal-pane').contains(field) ? null : toggleZenMode;
10402
- }
10741
+ if (ctrlShift && e.code === 'KeyZ') return inPageField(e) ? null : toggleZenMode;
10403
10742
  if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
10404
10743
  return () => openNewSession(null, e.code === 'KeyR');
10405
10744
  }
@@ -10493,6 +10832,7 @@ function openSession(sessionId) {
10493
10832
  const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
10494
10833
  if (!alreadyOpen) termState.openedByUser = sessionId;
10495
10834
  else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
10835
+ markSessionRead(sessionId);
10496
10836
  return fetchTasks(sessionId);
10497
10837
  }
10498
10838
 
@@ -10595,7 +10935,7 @@ function terminalKeyFilter(e) {
10595
10935
  return false;
10596
10936
  }
10597
10937
  if (ctrlOnly && e.code === 'KeyV') return false;
10598
- return !isHubKey(e);
10938
+ return !hub.forwards(e);
10599
10939
  }
10600
10940
 
10601
10941
  function oscColor(hex) {
@@ -10765,22 +11105,44 @@ function hideTerminalPrompt() {
10765
11105
 
10766
11106
  const TERMINAL_BOOT_VERBS = ['Waking Claude…', 'Clawding…', 'Warming up the terminal…', 'Summoning the session…'];
10767
11107
  const TERMINAL_BOOT_MAX_MS = 15000;
11108
+ // The server answers `ready` as soon as the socket attaches, even before Claude draws, so a wait
11109
+ // this long is the connection: under the hub a loopback connect can hang until its timeout.
11110
+ const TERMINAL_SLOW_CONNECT_MS = 1500;
10768
11111
  let terminalBootTimer = null;
11112
+ let terminalSlowTimer = null;
10769
11113
 
10770
11114
  // A new PTY shows only a blinking cursor until Claude draws its first frame.
10771
11115
  function showTerminalBoot() {
10772
11116
  document.getElementById('terminal-boot-verb').textContent = pickRandom(TERMINAL_BOOT_VERBS);
10773
11117
  document.getElementById('terminal-boot').classList.add('visible');
10774
11118
  clearTimeout(terminalBootTimer);
10775
- terminalBootTimer = setTimeout(hideTerminalBoot, TERMINAL_BOOT_MAX_MS);
11119
+ terminalBootTimer = setTimeout(() => {
11120
+ if (!terminalConnectSlow()) hideTerminalBoot();
11121
+ }, TERMINAL_BOOT_MAX_MS);
10776
11122
  }
10777
11123
 
10778
11124
  function hideTerminalBoot() {
10779
11125
  clearTimeout(terminalBootTimer);
10780
11126
  terminalBootTimer = null;
11127
+ clearTerminalSlow();
10781
11128
  document.getElementById('terminal-boot').classList.remove('visible');
10782
11129
  }
10783
11130
 
11131
+ function clearTerminalSlow() {
11132
+ clearTimeout(terminalSlowTimer);
11133
+ terminalSlowTimer = null;
11134
+ document.getElementById('terminal-boot').classList.remove('slow');
11135
+ }
11136
+
11137
+ function terminalConnectSlow() {
11138
+ return document.getElementById('terminal-boot').classList.contains('slow');
11139
+ }
11140
+
11141
+ function reconnectTerminal() {
11142
+ const id = termState.sessionId;
11143
+ if (id) openTerminal(id, terminalOpenMode(id));
11144
+ }
11145
+
10784
11146
  function showTerminalPrompt(sessionId, title, detail, choices, output = '') {
10785
11147
  const el = document.getElementById('terminal-prompt');
10786
11148
  const tail = output ? `<pre class="${TINTED_PRE_CLASS} terminal-prompt-output">${escapeHtml(output)}</pre>` : '';
@@ -10821,13 +11183,41 @@ function syncCloseGuard() {
10821
11183
  const on = termState.attached && termState.shown;
10822
11184
  if (termState.closeGuard === on) return;
10823
11185
  termState.closeGuard = on;
10824
- hubPost({ type: 'hub:closeGuard', on });
11186
+ hub.closeGuard(on);
10825
11187
  }
10826
11188
 
10827
11189
  window.addEventListener('beforeunload', (e) => {
10828
- if (termState.closeGuard && !window.__HUB__?.enabled) e.preventDefault();
11190
+ if (termState.closeGuard && !hub.inHub) e.preventDefault();
10829
11191
  });
10830
11192
 
11193
+ // A config-dir switch in the hub reloads this page, and coming back should put the cursor where it was.
11194
+ // The hub's palette takes focus first and clears activeElement here, so the last focus inside this
11195
+ // page is tracked instead; focus leaving the frame keeps it.
11196
+ let terminalHadFocus = false;
11197
+ document.addEventListener('focusin', () => {
11198
+ terminalHadFocus = terminalPaneFocused();
11199
+ });
11200
+ document.addEventListener('focusout', (e) => {
11201
+ if (e.relatedTarget) return;
11202
+ setTimeout(() => {
11203
+ if (document.hasFocus()) terminalHadFocus = terminalPaneFocused();
11204
+ });
11205
+ });
11206
+ window.addEventListener('pagehide', () => {
11207
+ try {
11208
+ if (termState.attached && termState.shown && terminalHadFocus) {
11209
+ sessionStorage.setItem(TERMINAL_FOCUS_KEY, currentSessionId);
11210
+ } else sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
11211
+ } catch (_) {}
11212
+ });
11213
+
11214
+ function restoreTerminalFocus(sessionId) {
11215
+ try {
11216
+ if (sessionStorage.getItem(TERMINAL_FOCUS_KEY) === sessionId) termState.focusNext = true;
11217
+ sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
11218
+ } catch (_) {}
11219
+ }
11220
+
10831
11221
  // The terminal stands in for the board, so it takes the board zone however focus arrives
10832
11222
  // (Tab, Alt+`, a click); leaveTerminalPane hands it back to the sidebar.
10833
11223
  document.getElementById('terminal-pane').addEventListener('focusin', () => {
@@ -10876,6 +11266,11 @@ async function openTerminal(sessionId, mode, attempt = 0) {
10876
11266
  const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/terminal/ws`);
10877
11267
  ws.binaryType = 'arraybuffer';
10878
11268
  termState.ws = ws;
11269
+ if (!attempt) {
11270
+ terminalSlowTimer = setTimeout(() => {
11271
+ if (termState.ws === ws) document.getElementById('terminal-boot').classList.add('slow');
11272
+ }, TERMINAL_SLOW_CONNECT_MS);
11273
+ }
10879
11274
  const spec = newSpecs.get(sessionId);
10880
11275
  let readyAt = 0;
10881
11276
  let refused = false;
@@ -10978,6 +11373,7 @@ function onTerminalMessage(sessionId, msg) {
10978
11373
  // The server has the prompt now; a later start of the same placeholder must not send it again.
10979
11374
  const spec = newSpecs.get(sessionId);
10980
11375
  if (spec) spec.prompt = null;
11376
+ clearTerminalSlow();
10981
11377
  hideTerminalPrompt();
10982
11378
  setTerminalStatus('');
10983
11379
  setTerminalAttached(true);
@@ -11270,13 +11666,15 @@ async function loadTerminals() {
11270
11666
  const res = await fetch('/api/terminals', { cache: 'no-store' });
11271
11667
  const list = (await res.json()).sessions || [];
11272
11668
  runningTerminals = new Set(list.map((t) => t.id));
11669
+ renderActivityChip();
11273
11670
  return list;
11274
11671
  }
11275
11672
 
11276
11673
  function setRunningTerminals(ids) {
11277
11674
  runningTerminals = new Set(ids);
11278
11675
  if (ids.some((id) => !sessions.some((s) => s.id === id))) fetchSessions(false).catch(() => {});
11279
- else renderSessions();
11676
+ else renderSessionViews();
11677
+ renderActivityChip();
11280
11678
  }
11281
11679
 
11282
11680
  function showSessionTerminal(sessionId) {
@@ -11637,6 +12035,7 @@ try {
11637
12035
  loadSessionGroups();
11638
12036
  initSessionGroupsDnd();
11639
12037
  initSessionPicker();
12038
+ initProjectPicker();
11640
12039
  try {
11641
12040
  const af = JSON.parse(store.getItem('activityFilter') || '[]');
11642
12041
  // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
@@ -11828,21 +12227,12 @@ if (urlState.search) {
11828
12227
  document.getElementById('boot-verb').textContent = pickRandom(verbs);
11829
12228
  }
11830
12229
 
11831
- Promise.all([
11832
- fetch('/hub-config')
11833
- .then((r) => r.json())
11834
- .then((cfg) => {
11835
- if (!cfg.enabled) return;
11836
- window.__HUB__ = cfg;
11837
- })
11838
- .catch(() => {}),
11839
- fetch('/api/config')
11840
- .then((r) => r.json())
11841
- .then((c) => {
11842
- appConfig = c;
11843
- })
11844
- .catch(() => {}),
11845
- ])
12230
+ fetch('/api/config')
12231
+ .then((r) => r.json())
12232
+ .then((c) => {
12233
+ appConfig = c;
12234
+ })
12235
+ .catch(() => {})
11846
12236
  .then(restorePendingSessions)
11847
12237
  .then(() => fetchSessions())
11848
12238
  .then(async () => {
@@ -11853,6 +12243,7 @@ Promise.all([
11853
12243
  showNoSession();
11854
12244
  }
11855
12245
  } else if (urlState.session) {
12246
+ restoreTerminalFocus(urlState.session);
11856
12247
  await fetchTasks(urlState.session);
11857
12248
  } else {
11858
12249
  const last = loadLastView();
@@ -11863,6 +12254,7 @@ Promise.all([
11863
12254
  showNoSession();
11864
12255
  }
11865
12256
  } else if (last?.view === 'session' && last.session && sessions.some((s) => s.id === last.session)) {
12257
+ restoreTerminalFocus(last.session);
11866
12258
  await fetchTasks(last.session);
11867
12259
  } else {
11868
12260
  showNoSession();
@@ -11906,49 +12298,65 @@ window.addEventListener('popstate', () => {
11906
12298
  //#endregion
11907
12299
 
11908
12300
  // #region HUB_INTEGRATION
11909
- // e.code travels with e.key because macOS composes Option+<key> into a character (Option+P is
11910
- // 'π'), so the key alone cannot identify the binding. The hub owns the keymap and normalizes;
11911
- // these tests only decide whether a press is the hub's to handle.
11912
- function isHubKey(e) {
11913
- if (!window.__HUB__?.enabled) return false;
11914
- if (hubKeys) return hubKeys.has(hubCombo(e));
11915
- if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) return true;
11916
- // Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
11917
- // keymap and ignores unbound letters.
11918
- if (
11919
- e.ctrlKey &&
11920
- e.altKey &&
11921
- !e.shiftKey &&
11922
- !e.metaKey &&
11923
- !CCK_CTRL_ALT_KEYS.has(e.code) &&
11924
- (/^[a-z]$/i.test(e.key) || /^Key[A-Z]$/.test(e.code))
11925
- ) {
11926
- return true;
11927
- }
11928
- return e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && (/^[1-9]$/.test(e.key) || /^Digit[1-9]$/.test(e.code));
11929
- }
11930
-
11931
- // The combos the hub binds, from its hub:keys message. Null until one arrives: a hub from before
11932
- // hub:keys sends none, and the fallback filter above is what such a hub expects.
11933
- let hubKeys = null;
12301
+ const costDetail = (session) => `?view=detail&session=${encodeURIComponent(session)}`;
12302
+ const projectQuery = (project) => `?${new URLSearchParams({ project })}`;
12303
+ const hub = ClaudeHub.connect({
12304
+ // A function, because the --*-url flags arrive with /api/config after connect.
12305
+ standalone: () => ({
12306
+ ...(appConfig.costUrl && {
12307
+ 'session.cost': (p) => p.session && new URL(costDetail(p.session), appConfig.costUrl).href,
12308
+ }),
12309
+ ...(appConfig.marketplaceUrl && {
12310
+ 'project.plugins': (p) => p.project && new URL(projectQuery(p.project), appConfig.marketplaceUrl).href,
12311
+ }),
12312
+ ...(appConfig.memoryUrl && {
12313
+ 'project.memory': (p) => p.project && new URL(projectQuery(p.project), appConfig.memoryUrl).href,
12314
+ }),
12315
+ }),
12316
+ });
11934
12317
 
11935
- // A copy of the hub's comboOf(): its names must match the hub:keys list.
11936
- function hubCombo(e) {
11937
- const lower = (e.key || '').toLowerCase();
11938
- const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
11939
- const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
11940
- const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
11941
- return [...mods, key].filter(Boolean).join('+');
12318
+ hub.onActive((active) => onHubActive(active));
12319
+
12320
+ // session.changed goes out once the switch settles, so arrowing through the list sends one event.
12321
+ let publishTimer = 0;
12322
+ let publishedSessionId;
12323
+ let cliOpenedId = null;
12324
+
12325
+ function schedulePublishSession() {
12326
+ clearTimeout(publishTimer);
12327
+ publishTimer = setTimeout(publishSession, 150);
12328
+ }
12329
+
12330
+ function publishSession() {
12331
+ if (currentSessionId === publishedSessionId) return;
12332
+ const s = currentSessionId ? sessions.find((x) => x.id === currentSessionId) : null;
12333
+ // A placeholder publishes once its real row replaces it.
12334
+ if (currentSessionId && (!s || s.placeholder)) return;
12335
+ publishedSessionId = currentSessionId;
12336
+ const source = currentSessionId === cliOpenedId ? 'cli' : 'user';
12337
+ cliOpenedId = null;
12338
+ hub.publish(
12339
+ 'session.changed',
12340
+ s && {
12341
+ sessionId: s.id,
12342
+ project: s.project,
12343
+ name: s.name || null,
12344
+ gitBranch: s.gitBranch || null,
12345
+ live: isSessionActive(s),
12346
+ source,
12347
+ },
12348
+ );
11942
12349
  }
11943
12350
 
11944
- document.addEventListener('keydown', (e) => {
11945
- if (!isHubKey(e)) return;
12351
+ // The help modal stops click propagation, so the document handler below never sees this link.
12352
+ // The service worker can pair a cached page that lacks the link with this script.
12353
+ document.getElementById('help-docs')?.addEventListener('click', (e) => {
11946
12354
  e.preventDefault();
11947
- hubPost({ type: 'hub:keydown', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
12355
+ hub.openExternal(e.currentTarget.href);
11948
12356
  });
11949
12357
 
11950
12358
  document.addEventListener('click', (e) => {
11951
- if (!window.__HUB__?.enabled) return;
12359
+ if (!hub.inHub) return;
11952
12360
  const a = e.target.closest?.('a[href]');
11953
12361
  if (!a) return;
11954
12362
  const href = a.getAttribute('href');
@@ -11963,78 +12371,34 @@ document.addEventListener('click', (e) => {
11963
12371
  if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
11964
12372
  e.preventDefault();
11965
12373
  e.stopPropagation();
11966
- hubPost({ type: 'hub:openExternal', url: url.href });
12374
+ hub.openExternal(url.href);
11967
12375
  });
11968
12376
 
11969
- // The click handler above only catches anchors. A script open goes the same way, because
11970
- // in the hub's installed PWA window a framed app's own `_blank` open does nothing.
11971
- function openExternal(url) {
11972
- if (window.__HUB__?.enabled) hubPost({ type: 'hub:openExternal', url });
11973
- else window.open(url, '_blank', 'noopener');
11974
- }
11975
-
11976
- window.hubNavigate = function hubNavigate(app, url) {
11977
- if (!window.__HUB__?.enabled) return;
11978
- hubPost({ type: 'hub:navigate', app, url });
11979
- };
11980
-
11981
- // Hoisted out of initHubTheme so initHubProject can share it.
11982
- const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
11983
-
11984
- // Every send is addressed to the hub explicitly. With targetOrigin '*' any page that
11985
- // framed this app also received the forwarded keystrokes and navigation intents.
11986
- function hubPost(message) {
11987
- const origin = hubOrigin();
11988
- if (origin) window.parent?.postMessage(message, origin);
11989
- }
11990
-
11991
12377
  (function initHubTheme() {
11992
12378
  const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
11993
12379
  const getColorTheme = () => document.body.dataset.colorTheme || 'ember';
11994
- // lastTheme/lastColorTheme are updated synchronously when applying a hub
11995
- // message, so the (async) observer sees no diff and doesn't echo it back.
11996
- let lastTheme = getTheme();
11997
- let lastColorTheme = getColorTheme();
11998
- window.addEventListener('message', (e) => {
11999
- if (e.source !== window.parent || e.origin !== hubOrigin()) return;
12000
- if (e.data?.type !== 'hub:theme') return;
12001
- if (typeof e.data.colorTheme === 'string' && e.data.colorTheme !== getColorTheme()) {
12002
- setColorTheme(e.data.colorTheme);
12003
- lastColorTheme = getColorTheme();
12004
- }
12005
- if (getTheme() !== e.data.theme) {
12006
- window.toggleTheme();
12007
- lastTheme = getTheme();
12008
- }
12380
+ const report = hub.bindTheme({
12381
+ get: () => ({ theme: getTheme(), colorTheme: getColorTheme() }),
12382
+ set: ({ theme, colorTheme }) => {
12383
+ if (colorTheme !== getColorTheme()) setColorTheme(colorTheme);
12384
+ if (theme !== getTheme()) window.toggleTheme();
12385
+ },
12009
12386
  });
12010
- new MutationObserver(() => {
12011
- const t = getTheme();
12012
- const ct = getColorTheme();
12013
- if (t === lastTheme && ct === lastColorTheme) return;
12014
- lastTheme = t;
12015
- lastColorTheme = ct;
12016
- hubPost({ type: 'hub:theme', theme: t, colorTheme: ct });
12017
- }).observe(document.body, {
12387
+ new MutationObserver(report).observe(document.body, {
12018
12388
  attributes: true,
12019
12389
  attributeFilter: ['class', 'data-color-theme'],
12020
12390
  });
12021
12391
  })();
12022
12392
 
12023
- (function initHubKeys() {
12024
- window.addEventListener('message', (e) => {
12025
- if (e.source !== window.parent || e.origin !== hubOrigin()) return;
12026
- if (e.data?.type !== 'hub:keys' || !Array.isArray(e.data.keys)) return;
12027
- hubKeys = new Set(e.data.keys.filter((k) => typeof k === 'string'));
12028
- });
12029
- })();
12393
+ // Under the hub the picker lists the hub's themes, which include the user's own (protocol section 6, rule 5).
12394
+ hub.onThemes((themes) => {
12395
+ buildThemeMenu(themes.map((t) => [t.id, t.label]));
12396
+ syncColorThemeSelect(document.body.dataset.colorTheme);
12397
+ });
12030
12398
 
12031
- (function initHubProject() {
12032
- window.addEventListener('message', (e) => {
12033
- if (e.source !== window.parent || e.origin !== hubOrigin()) return;
12034
- if (e.data?.type !== 'hub:project') return;
12035
- const dirPath = e.data.project;
12036
- if (typeof dirPath !== 'string' || !dirPath || dirPath === filterProject) return;
12037
- filterByProject(dirPath);
12038
- });
12039
- })();
12399
+ hub.subscribe('project.changed', (p) => {
12400
+ const dirPath = p?.project;
12401
+ if (typeof dirPath !== 'string' || !dirPath || dirPath === filterProject) return;
12402
+ filterByProject(dirPath);
12403
+ });
12040
12404
  // #endregion HUB_INTEGRATION