claude-code-kanban 4.31.1 → 5.0.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);
@@ -430,25 +450,24 @@ function isSessionLive(s) {
430
450
  const ACTIVITY_PREDICATES = {
431
451
  waiting: isWaitingSession,
432
452
  active: isActiveSession,
453
+ terminal: (s) => runningTerminals.has(s.id),
433
454
  };
434
455
 
435
- let lastChipKey = '';
456
+ let lastChipHtml = '';
436
457
 
437
458
  function renderActivityChip() {
438
459
  const container = document.getElementById('activity-chips');
439
460
  if (!container) return;
440
461
 
441
- let waiting = 0;
442
- let active = 0;
462
+ const counts = { waiting: 0, active: 0, terminal: 0 };
443
463
  for (const s of sessions) {
444
464
  if (dismissedSessionIds.has(s.id)) continue;
445
- if (s.hasWaitingForUser) waiting++;
446
- else if (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents) active++;
465
+ for (const kind in counts) if (ACTIVITY_PREDICATES[kind](s)) counts[kind]++;
447
466
  }
448
-
449
- const key = `${waiting}|${active}|${dismissedSessionIds.size}|${[...activityFilter].sort().join(',')}`;
450
- if (key === lastChipKey) return;
451
- lastChipKey = key;
467
+ const { waiting, active, terminal: terminals } = counts;
468
+ const showTerminals = terminalAvailable();
469
+ const maxTerminals = appConfig.terminal?.maxSessions;
470
+ const nearCap = showTerminals && runningTerminals.size >= maxTerminals * 0.8;
452
471
 
453
472
  const chips = [
454
473
  {
@@ -464,14 +483,27 @@ function renderActivityChip() {
464
483
  title: `${active} session${active === 1 ? '' : 's'} with running work or recent activity`,
465
484
  },
466
485
  ];
486
+ if (showTerminals) {
487
+ chips.push({
488
+ kind: 'terminal',
489
+ count: terminals,
490
+ label: nearCap ? `${runningTerminals.size}/${maxTerminals}` : `${terminals}`,
491
+ title: nearCap
492
+ ? `${runningTerminals.size} of ${maxTerminals} terminals open; end some to start more`
493
+ : `${terminals} session${terminals === 1 ? '' : 's'} running in a terminal here`,
494
+ icon: ICON_TERMINAL,
495
+ warn: nearCap,
496
+ });
497
+ }
467
498
 
468
- container.innerHTML = chips
499
+ const html = chips
469
500
  .map((c) => {
470
501
  const isOn = activityFilter.has(c.kind);
471
502
  const classes = [
472
503
  'activity-chip',
473
504
  `activity-${c.kind}`,
474
- c.count === 0 ? 'activity-zero' : '',
505
+ c.count === 0 && !c.warn ? 'activity-zero' : '',
506
+ c.warn ? 'activity-warn' : '',
475
507
  isOn ? 'activity-filter-on' : '',
476
508
  ]
477
509
  .filter(Boolean)
@@ -483,12 +515,15 @@ function renderActivityChip() {
483
515
  onclick="setActivityFilter('${escAttrJs(c.kind)}')"
484
516
  aria-pressed="${isOn ? 'true' : 'false'}"
485
517
  title="${escapeHtml(c.title + hint)}">
486
- <span class="activity-dot"></span>
518
+ ${c.icon ? `<span class="activity-icon">${c.icon}</span>` : '<span class="activity-dot"></span>'}
487
519
  <span class="activity-label">${escapeHtml(c.label)}</span>
488
520
  </button>
489
521
  `;
490
522
  })
491
523
  .join('');
524
+ if (html === lastChipHtml) return;
525
+ lastChipHtml = html;
526
+ container.innerHTML = html;
492
527
  }
493
528
 
494
529
  function toggleActivityKind(kind) {
@@ -524,6 +559,7 @@ let lastCurrentTasksHash = '';
524
559
 
525
560
  async function fetchTasks(sessionId) {
526
561
  try {
562
+ const refresh = viewMode === 'session' && sessionId === currentSessionId;
527
563
  viewMode = 'session';
528
564
  document.getElementById('message-toggle')?.style.removeProperty('display');
529
565
  const res = await fetch(`/api/sessions/${sessionId}`);
@@ -532,9 +568,7 @@ async function fetchTasks(sessionId) {
532
568
  const newTasks = await res.json();
533
569
 
534
570
  const hash = JSON.stringify(newTasks);
535
- if (sessionId === currentSessionId && hash === lastCurrentTasksHash) {
536
- return;
537
- }
571
+ if (sessionId === currentSessionId && hash === lastCurrentTasksHash) return;
538
572
  lastCurrentTasksHash = hash;
539
573
 
540
574
  currentTasks = newTasks;
@@ -548,18 +582,24 @@ async function fetchTasks(sessionId) {
548
582
  if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
549
583
  revealedStorageSessionId = null;
550
584
  }
551
- if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
585
+ if (currentSessionId && currentSessionId !== sessionId && deferredPinPlacement.delete(currentSessionId))
586
+ expandPinnedFor(currentSessionId);
552
587
  if (lastSessionId !== sessionId) setSwapPair(sessionId, lastSessionId);
553
588
  const switched = sessionId !== currentSessionId;
554
589
  currentSessionId = sessionId;
590
+ markOpenSessionRead();
555
591
  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();
592
+ // A task change in the open session must not reset the messages, the agents or the owner
593
+ // filter: that reset rebuilds the panels and reads as a reload after each card move.
594
+ if (!refresh) {
595
+ currentPins = loadPins(sessionId);
596
+ ownerFilter = '';
597
+ resetMessageScrollState();
598
+ for (const k of Object.keys(ownerColorCache)) delete ownerColorCache[k];
599
+ for (const k of Object.keys(teamColorMap)) delete teamColorMap[k];
600
+ sessionJustSelected = true;
601
+ resetAgentState();
602
+ }
563
603
  updateUrl();
564
604
  renderSession();
565
605
  renderSessions();
@@ -1913,6 +1953,7 @@ function toggleSessionPin(sessionId) {
1913
1953
  } else {
1914
1954
  pinnedSessionIds.add(sessionId);
1915
1955
  if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
1956
+ expandPinnedFor(sessionId);
1916
1957
  }
1917
1958
  savePinnedSessions();
1918
1959
  offloadSessionPin(sessionId);
@@ -1951,6 +1992,7 @@ function handleSessionPinEvent({ id, state }) {
1951
1992
  pinnedSessionIds.add(id);
1952
1993
  stickySessionIds.add(id);
1953
1994
  }
1995
+ expandPinnedFor(id);
1954
1996
  savePinnedSessions();
1955
1997
  renderSessions();
1956
1998
  }
@@ -2411,6 +2453,10 @@ function showToast(msg, type, action) {
2411
2453
  _toastTimer = setTimeout(hide, action ? 8000 : 2000);
2412
2454
  }
2413
2455
 
2456
+ function sendMessageAddress(peerName) {
2457
+ return `SendMessage(${peerName})`;
2458
+ }
2459
+
2414
2460
  async function copyWithFeedback(text, btn) {
2415
2461
  if (btn.dataset.copying) return;
2416
2462
  try {
@@ -3296,7 +3342,7 @@ function getFilteredSessions() {
3296
3342
  // Skip pinned sessions — they are prepended separately below (lines ~2180) to preserve stable position.
3297
3343
  const filteredIds = new Set(filteredSessions.map((s) => s.id));
3298
3344
  for (const id of [revealedPlanSessionId, revealedStorageSessionId, currentSessionId]) {
3299
- if (id && !filteredIds.has(id) && !isAnyPinned(id)) {
3345
+ if (id && !filteredIds.has(id) && !(isAnyPinned(id) && activityFilter.size === 0)) {
3300
3346
  const session = sessions.find((s) => s.id === id);
3301
3347
  if (session) {
3302
3348
  const insertAt = filteredSessions.findIndex((s) => s.modifiedAt < session.modifiedAt);
@@ -3315,7 +3361,9 @@ function getFilteredSessions() {
3315
3361
 
3316
3362
  if (activityFilter.size > 0) {
3317
3363
  const preds = [...activityFilter].map((k) => ACTIVITY_PREDICATES[k]).filter(Boolean);
3318
- if (preds.length) filteredSessions = filteredSessions.filter((s) => preds.some((p) => p(s)));
3364
+ if (preds.length) {
3365
+ filteredSessions = filteredSessions.filter((s) => s.id === currentSessionId || preds.some((p) => p(s)));
3366
+ }
3319
3367
  }
3320
3368
 
3321
3369
  if (searchQuery) {
@@ -3446,9 +3494,14 @@ function renderSessions() {
3446
3494
  const bookmarksCount = loadPins(session.id).length;
3447
3495
  const hasScratchpad = _hasScratchpad(_sessionScratchpadKey(session.id));
3448
3496
  const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
3497
+ // Open tasks keep an idle session warm, but the green dot is for a session that is working.
3498
+ const idleClass =
3499
+ tempClass === 'warm' && !session.hasWaitingForUser && !session.hasRecentLog && !session.hasRunningAgents
3500
+ ? 'idle'
3501
+ : '';
3449
3502
  const sid = escAttrJs(session.id);
3450
3503
  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)}">
3504
+ <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} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
3452
3505
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
3453
3506
  <div class="session-name">${escapeHtml(sessionName)}</div>
3454
3507
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
@@ -3506,12 +3559,13 @@ function renderSessions() {
3506
3559
  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
3560
  };
3508
3561
  // `ungroup` marks the label as the drop target for pulling an item out of a named group.
3509
- const sectionHtml = (key, text, ungroup, body) => {
3562
+ const sectionHtml = (key, text, ungroup, body, empty = false) => {
3510
3563
  const collapsed = collapsedProjectGroups.has(key);
3511
3564
  const escPath = escapeHtml(key);
3512
3565
  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>`;
3566
+ const emptyCls = empty ? ' sg-section-empty' : '';
3567
+ 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>
3568
+ <div class="sg-section-body${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}">${body}</div>`;
3515
3569
  };
3516
3570
 
3517
3571
  const renderGroupSessions = (sessions, pinKey) => {
@@ -3521,7 +3575,7 @@ function renderSessions() {
3521
3575
  const gIdlePinned = gPinned.filter((s) => !isSessionActive(s));
3522
3576
  const gUnpinned = sessions.filter((s) => !isPlacedPinned(s.id) || isSessionActive(s) || isPlacedSticky(s.id));
3523
3577
  const pinCollapsed = collapsedProjectGroups.has(pinKey);
3524
- if (gIdlePinned.length === 0 && !pinCollapsed) return gUnpinned.map(renderSessionCard).join('');
3578
+ if (gIdlePinned.length === 0) return gUnpinned.map(renderSessionCard).join('');
3525
3579
  return (
3526
3580
  '<div class="pinned-sub-section">' +
3527
3581
  '<div class="pinned-sub-header' +
@@ -3732,14 +3786,15 @@ function renderSessions() {
3732
3786
  }
3733
3787
  }
3734
3788
 
3735
- html += sectioned ? sectionHtml(SECTION_PROJECTS, 'Projects', true, projectsHtml) : projectsHtml;
3789
+ const noProjects = sortedGroups.length === 0 && ungrouped.length === 0;
3790
+ html += sectioned ? sectionHtml(SECTION_PROJECTS, 'Projects', true, projectsHtml, noProjects) : projectsHtml;
3736
3791
 
3737
3792
  sessionsList.innerHTML = html;
3738
3793
  } else {
3739
3794
  const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
3740
3795
  const restHtml =
3741
3796
  sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
3742
- const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml) : restHtml;
3797
+ const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
3743
3798
  sessionsList.innerHTML = html + sgSectionHtml(true) + tail;
3744
3799
  }
3745
3800
 
@@ -3788,8 +3843,7 @@ function renderSession() {
3788
3843
  // happened to open the info modal or zen panel.
3789
3844
  loadSessionPads(currentSessionId);
3790
3845
 
3791
- const displayName =
3792
- session.customTitle || session.name || session.gitBranch || session.description || currentSessionId;
3846
+ const displayName = session.name || session.description || currentSessionId;
3793
3847
 
3794
3848
  sessionTitle.textContent = displayName;
3795
3849
 
@@ -3842,8 +3896,17 @@ function renderProjectView() {
3842
3896
  renderSessions();
3843
3897
  }
3844
3898
 
3899
+ // Claude Code keeps a blocker's id in blockedBy after the blocker completes.
3900
+ function openBlockers(task) {
3901
+ const list = (t) => t.sessionId || t._taskDir;
3902
+ return (task.blockedBy || []).filter(
3903
+ (id) => currentTasks.find((t) => t.id === id && list(t) === list(task))?.status !== 'completed',
3904
+ );
3905
+ }
3906
+
3845
3907
  function renderTaskCard(task) {
3846
- const isBlocked = task.blockedBy && task.blockedBy.length > 0;
3908
+ const blockers = openBlockers(task);
3909
+ const isBlocked = blockers.length > 0;
3847
3910
  const useSlug = viewMode === 'project';
3848
3911
  const taskId = useSlug ? `${(task._taskDir || task.sessionId || '')?.slice(0, 4)}-${task.id}` : task.id;
3849
3912
  const statusClass = task.status.replace('_', '-');
@@ -3877,7 +3940,7 @@ function renderTaskCard(task) {
3877
3940
  </div>
3878
3941
  <div class="task-title">${escapeHtml(task.subject)}</div>
3879
3942
  ${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>` : ''}
3943
+ ${isBlocked ? `<div class="task-blocked">Waiting on ${blockers.map((id) => `#${id}`).join(', ')}</div>` : ''}
3881
3944
  ${task.description ? `<div class="task-desc">${escapeHtml(task.description.split('\n')[0])}</div>` : ''}
3882
3945
  </div>
3883
3946
  `;
@@ -3960,7 +4023,7 @@ function renderKanban() {
3960
4023
  // away mid-typing -- leave the column alone until the input is gone.
3961
4024
  if (!addingTask) {
3962
4025
  const addTile = canAddTask()
3963
- ? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this)">${plusIcon}<span>Add task</span></button>`
4026
+ ? `<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
4027
  : '';
3965
4028
  writes.push([
3966
4029
  pendingTasks,
@@ -4143,17 +4206,21 @@ async function onColumnDrop(e) {
4143
4206
  (t) => t.id === taskId && (t._taskDir === sessionId || (t.sessionId || currentSessionId) === sessionId),
4144
4207
  );
4145
4208
  if (!task || task.status === newStatus) return;
4209
+ const oldStatus = task.status;
4210
+ task.status = newStatus;
4211
+ renderKanban();
4146
4212
  try {
4147
4213
  const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
4148
4214
  method: 'PUT',
4149
4215
  headers: { 'Content-Type': 'application/json' },
4150
4216
  body: JSON.stringify({ status: newStatus }),
4151
4217
  });
4152
- if (res.ok) {
4153
- task.status = newStatus;
4154
- renderKanban();
4155
- }
4218
+ if (res.ok) return;
4156
4219
  } catch (_) {}
4220
+ if (task.status === newStatus) {
4221
+ task.status = oldStatus;
4222
+ renderKanban();
4223
+ }
4157
4224
  }
4158
4225
 
4159
4226
  //#endregion
@@ -4899,6 +4966,7 @@ function getNavigableItems() {
4899
4966
  // into the body to reach the blocks inside.
4900
4967
  const walkTopLevel = (children) => {
4901
4968
  for (const el of children) {
4969
+ if (el.classList.contains('sg-section-empty')) continue;
4902
4970
  if (
4903
4971
  el.classList.contains('project-group-header') ||
4904
4972
  el.classList.contains('session-group-header') ||
@@ -5007,14 +5075,11 @@ function expandActiveGroups({ onlyNew = false } = {}) {
5007
5075
  // it (pinned, not sticky, idle — see renderGroupSessions), and — since which section holds it
5008
5076
  // depends on the view — every section header. Returns whether anything changed.
5009
5077
  function uncollapseFor(session) {
5010
- const project = session.project || '__ungrouped__';
5011
5078
  const group = sgGroupForSession(session);
5012
- const inPinned = isPlacedPinned(session.id) && !isPlacedSticky(session.id) && !isSessionActive(session);
5013
5079
  const keys = [
5014
- project,
5015
- inPinned && pinKey(project),
5080
+ session.project || '__ungrouped__',
5081
+ ...pinnedCollapseKeys(session),
5016
5082
  group && sgKey(group.id),
5017
- group && inPinned && `__pinned_group_${group.id}__`,
5018
5083
  SECTION_GROUPS,
5019
5084
  SECTION_PROJECTS,
5020
5085
  SECTION_SESSIONS,
@@ -5026,6 +5091,24 @@ function uncollapseFor(session) {
5026
5091
  return changed;
5027
5092
  }
5028
5093
 
5094
+ function pinnedCollapseKeys(session) {
5095
+ if (!isPlacedPinned(session.id) || isPlacedSticky(session.id) || isSessionActive(session)) return [];
5096
+ const group = sgGroupForSession(session);
5097
+ const host = sgHostOf(group, session);
5098
+ return [pinKey(session.project || '__ungrouped__'), host && pinKey(host), group && `__pinned_group_${group.id}__`];
5099
+ }
5100
+
5101
+ // A card that moves into a collapsed Pinned sub-section would vanish from under the click.
5102
+ function expandPinnedFor(sessionId) {
5103
+ const session = sessions.find((s) => s.id === sessionId);
5104
+ if (!session) return;
5105
+ let changed = false;
5106
+ for (const key of pinnedCollapseKeys(session)) {
5107
+ if (key && collapsedProjectGroups.delete(key)) changed = true;
5108
+ }
5109
+ if (changed) persistCollapsedGroups();
5110
+ }
5111
+
5029
5112
  function isGroupHeader(el) {
5030
5113
  return el.matches(COLLAPSIBLE_HEADER_SELECTOR);
5031
5114
  }
@@ -5088,7 +5171,7 @@ function activateSelectedSession(items) {
5088
5171
  }
5089
5172
  }
5090
5173
 
5091
- function setFocusZone(zone) {
5174
+ function setFocusZone(zone, target) {
5092
5175
  clearKbSelection();
5093
5176
  clearTaskSelection();
5094
5177
 
@@ -5097,8 +5180,11 @@ function setFocusZone(zone) {
5097
5180
  expandSidebar();
5098
5181
  const items = getNavigableItems();
5099
5182
  if (items.length > 0) {
5183
+ const targetIdx = target ? items.indexOf(target) : -1;
5100
5184
  const activeIdx = items.findIndex((el) => el.classList.contains('active'));
5101
- if (activeIdx >= 0) {
5185
+ if (targetIdx >= 0) {
5186
+ selectSessionByIndex(targetIdx, items);
5187
+ } else if (activeIdx >= 0) {
5102
5188
  selectSessionByIndex(activeIdx);
5103
5189
  } else if (selectedSessionKbId) {
5104
5190
  const restoredIdx = items.findIndex((el) => getKbId(el) === selectedSessionKbId);
@@ -5129,6 +5215,18 @@ function setFocusZone(zone) {
5129
5215
  }
5130
5216
  }
5131
5217
 
5218
+ // A pointer click puts the keyboard cursor on the row, unless a task card or the terminal holds it.
5219
+ // Enter's el.click() arrives with detail 0 and already has the cursor.
5220
+ sessionsList.addEventListener('click', (e) => {
5221
+ if (e.detail === 0 || e.target.closest(SG_ACTION_SELECTOR)) return;
5222
+ const el = e.target.closest(
5223
+ '.session-item, .project-group-header, .session-group-header, .pinned-sub-header, .sg-section-toggle',
5224
+ );
5225
+ if (!el) return;
5226
+ const taskInUse = selectedTaskId && selectedSessionId === (el.dataset.sessionId || currentSessionId);
5227
+ if (focusZone === 'sidebar' || (!taskInUse && !terminalPaneFocused())) setFocusZone('sidebar', el);
5228
+ });
5229
+
5132
5230
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5133
5231
  function getAvailableTasksOptions(currentTaskId = null) {
5134
5232
  const pending = currentTasks.filter((t) => t.status === 'pending' && t.id !== currentTaskId);
@@ -5198,7 +5296,8 @@ async function showTaskDetail(taskId, sessionId = null) {
5198
5296
  pending: '<span class="detail-status pending"><span class="dot"></span>Pending</span>',
5199
5297
  };
5200
5298
 
5201
- const isBlocked = task.blockedBy && task.blockedBy.length > 0;
5299
+ const blockers = openBlockers(task);
5300
+ const isBlocked = blockers.length > 0;
5202
5301
  const actualSessionId = task.sessionId || sessionId || currentSessionId;
5203
5302
 
5204
5303
  detailContent.innerHTML = `
@@ -5231,12 +5330,12 @@ async function showTaskDetail(taskId, sessionId = null) {
5231
5330
  }
5232
5331
 
5233
5332
  ${
5234
- task.blockedBy && task.blockedBy.length > 0
5333
+ isBlocked
5235
5334
  ? `
5236
5335
  <div class="detail-section">
5237
5336
  <div class="detail-label">Blocked By</div>
5238
5337
  <div class="detail-deps">
5239
- <div class="detail-box blocked"><strong>Blocked by:</strong> ${task.blockedBy.map((id) => `#${id}`).join(', ')}</div>
5338
+ <div class="detail-box blocked"><strong>Blocked by:</strong> ${blockers.map((id) => `#${id}`).join(', ')}</div>
5240
5339
  </div>
5241
5340
  </div>`
5242
5341
  : ''
@@ -5495,147 +5594,219 @@ async function confirmDelete() {
5495
5594
  //#endregion
5496
5595
 
5497
5596
  //#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
- ],
5597
+ // Each tab shows its groups two to a row; a pair shares grid rows, so the two headings sit level and
5598
+ // the shorter group leaves empty rows. `combo` joins the keys with a plus (a chord) instead of
5599
+ // listing them as alternatives. The `hub` tab only works inside Claude Code Hub.
5600
+ const SHORTCUT_TABS = [
5601
+ {
5602
+ title: 'Board',
5603
+ groups: [
5604
+ {
5605
+ title: 'Navigate',
5606
+ rows: [
5607
+ { keys: ['↓'], label: 'Next item' },
5608
+ { keys: ['↑'], label: 'Previous item' },
5609
+ { keys: ['←'], label: 'Left column / collapse group' },
5610
+ { keys: ['→'], label: 'Right column / expand group' },
5611
+ { keys: ['Tab'], label: 'Switch sidebar ↔ board' },
5612
+ { keys: ['Enter', 'Space'], label: 'Open selected item' },
5613
+ ],
5614
+ },
5615
+ {
5616
+ title: 'Board',
5617
+ rows: [
5618
+ { keys: ['Enter'], label: 'Toggle task detail panel' },
5619
+ { keys: ['D'], label: 'Delete selected task' },
5620
+ { keys: ['N'], label: 'Toggle scratchpad (in the sidebar: the item under the cursor)' },
5621
+ { keys: ['R'], label: 'Refresh data' },
5622
+ { keys: ['Esc'], label: 'Close panel / clear selection' },
5623
+ ],
5624
+ },
5625
+ {
5626
+ title: 'View',
5627
+ rows: [
5628
+ { keys: ['['], label: 'Toggle sidebar' },
5629
+ { keys: ['T'], label: 'Toggle theme' },
5630
+ { keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
5631
+ { keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
5632
+ { keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
5633
+ { keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
5634
+ { keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
5635
+ { keys: ['?'], label: 'Show this help' },
5636
+ ],
5637
+ },
5638
+ {
5639
+ title: 'Copy',
5640
+ rows: [
5641
+ { keys: ['Shift', 'C'], combo: true, label: 'Session id' },
5642
+ { keys: ['Ctrl', 'Shift', 'C'], combo: true, label: 'Session name (SendMessage address)' },
5643
+ ],
5644
+ },
5645
+ ],
5646
+ },
5647
+ {
5648
+ title: 'Session',
5649
+ groups: [
5650
+ {
5651
+ title: 'Session',
5652
+ rows: [
5653
+ { keys: ['P'], label: 'Open plan' },
5654
+ { keys: ['Ctrl', 'Shift', 'P'], combo: true, label: 'Session picker' },
5655
+ { keys: ['Shift', 'P'], combo: true, label: 'Project picker (this board)' },
5656
+ { keys: ['I'], label: 'Session info' },
5657
+ { keys: ['.'], label: 'Pin / unpin' },
5658
+ { keys: ['>'], label: 'Toggle sticky' },
5659
+ { keys: ['Shift', 'F10'], combo: true, label: 'Move to group (or Menu key)' },
5660
+ { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session' },
5661
+ { keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
5662
+ { keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
5663
+ { keys: ['↑', '↓'], label: 'Previous / next message in detail' },
5664
+ { keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt' },
5665
+ ],
5666
+ },
5667
+ {
5668
+ title: 'Sessions',
5669
+ rows: [
5670
+ { keys: ['Ctrl', 'Alt', 'N'], combo: true, label: 'New session' },
5671
+ { keys: ['Ctrl', 'Alt', 'R'], combo: true, label: 'Resume session (claude -r)' },
5672
+ { keys: ['Ctrl', 'Alt', 'S'], combo: true, label: 'Swap to previous session' },
5673
+ ],
5674
+ },
5675
+ ],
5676
+ },
5677
+ {
5678
+ title: 'Terminal',
5679
+ groups: [
5680
+ {
5681
+ title: 'Terminal',
5682
+ rows: [
5683
+ { keys: ['Ctrl', '`'], combo: true, label: 'Show / hide terminal' },
5684
+ { keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
5685
+ { keys: ['Alt', 'Shift', '`'], combo: true, label: 'End and close terminal' },
5686
+ { keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
5687
+ { keys: ['Ctrl', '+/−/0'], combo: true, label: 'Terminal text size' },
5688
+ ],
5689
+ },
5690
+ ],
5691
+ },
5692
+ {
5693
+ title: 'Hub',
5694
+ hub: true,
5695
+ groups: [
5696
+ {
5697
+ title: 'Hub',
5698
+ rows: [
5699
+ { keys: ['M'], label: 'Jump to marketplace' },
5700
+ { keys: ['$'], label: 'Jump to cost' },
5701
+ { keys: ['Ctrl', 'M'], combo: true, label: 'Jump to memory' },
5702
+ { keys: ['Ctrl', 'Alt', '←/→'], combo: true, label: 'Previous / next hub app' },
5703
+ { keys: ['Alt', '1…9'], combo: true, label: 'Jump to hub app by number' },
5704
+ { keys: ['Ctrl', 'Alt', 'P'], combo: true, label: 'Project picker' },
5705
+ { keys: ['Ctrl', 'Alt', 'W'], combo: true, label: 'Config dir picker' },
5706
+ { keys: ['Ctrl', 'Alt', 'A'], combo: true, label: 'App launcher' },
5707
+ ],
5708
+ },
5709
+ ],
5710
+ },
5598
5711
  ];
5599
5712
 
5600
- const EMPTY_GROUP = { title: '', rows: [] };
5601
-
5602
5713
  // Interleaves each pair's rows left-then-right so CSS grid auto-placement lands
5603
5714
  // them on shared row tracks (see .shortcuts in style.css).
5604
- function buildHelpShortcuts() {
5715
+ function buildHelpShortcuts(groups) {
5605
5716
  const cells = [];
5606
- SHORTCUT_PAIRS.forEach(([left, right = EMPTY_GROUP], pair) => {
5717
+ for (let pair = 0; pair < groups.length; pair += 2) {
5607
5718
  const first = pair === 0 ? ' sc-first' : '';
5608
5719
  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++) {
5720
+ [groups[pair], 'sc-l'],
5721
+ [groups[pair + 1], 'sc-r'],
5722
+ ].filter(([g]) => g);
5723
+ for (const [g, side] of sides)
5724
+ cells.push(`<div class="${escapeHtml(`sc-group ${side}${first}`)}">${escapeHtml(g.title)}</div>`);
5725
+ for (let i = 0; i < Math.max(...sides.map(([g]) => g.rows.length)); i++) {
5616
5726
  for (const [group, side] of sides) {
5617
5727
  const row = group.rows[i];
5618
5728
  if (!row) continue;
5619
5729
  const sep = row.combo ? '<span class="sc-plus">+</span>' : '<span class="sc-or">/</span>';
5620
5730
  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
- );
5731
+ const label = escapeHtml(row.label);
5732
+ cells.push(`<dt class="${escapeHtml(side)}">${keys}</dt><dd class="${escapeHtml(side)}">${label}</dd>`);
5625
5733
  }
5626
5734
  }
5627
- });
5735
+ }
5628
5736
  return cells.join('');
5629
5737
  }
5630
5738
 
5739
+ let helpTab = 0;
5740
+ const helpFilter = document.getElementById('help-filter');
5741
+
5742
+ // Hub keys do nothing outside Claude Code Hub, so the standalone board drops that tab.
5743
+ const helpTabs = () => SHORTCUT_TABS.filter((t) => hub.inHub || !t.hub);
5744
+
5745
+ // The matching rows of every tab, in their groups.
5746
+ function filteredShortcutGroups(query) {
5747
+ return helpTabs()
5748
+ .flatMap((t) => t.groups)
5749
+ .map((g) => ({ title: g.title, rows: g.rows.filter((r) => fuzzyMatch(`${r.label} ${r.keys.join(' ')}`, query)) }))
5750
+ .filter((g) => g.rows.length);
5751
+ }
5752
+
5753
+ function renderHelp() {
5754
+ const tabs = helpTabs();
5755
+ helpTab = Math.min(helpTab, tabs.length - 1);
5756
+ const query = helpFilter.value.trim();
5757
+ document.getElementById('help-tabs').innerHTML = tabs
5758
+ .map((t, i) => {
5759
+ const on = !query && i === helpTab;
5760
+ return `<button type="button" role="tab" class="help-tab${on ? ' active' : ''}" aria-selected="${escapeHtml(String(on))}" data-idx="${i}">${escapeHtml(t.title)}</button>`;
5761
+ })
5762
+ .join('');
5763
+ const groups = query ? filteredShortcutGroups(query) : tabs[helpTab].groups;
5764
+ document.getElementById('help-shortcuts').innerHTML = groups.length
5765
+ ? buildHelpShortcuts(groups)
5766
+ : `<div class="sc-empty">No shortcut matches "${escapeHtml(query)}"</div>`;
5767
+ }
5768
+
5769
+ function clearHelpFilter() {
5770
+ helpFilter.value = '';
5771
+ renderHelp();
5772
+ }
5773
+
5774
+ function selectHelpTab(idx) {
5775
+ helpTab = idx;
5776
+ clearHelpFilter();
5777
+ }
5778
+
5631
5779
  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');
5780
+ clearHelpFilter();
5781
+ document.getElementById('help-modal').classList.add('visible');
5782
+ helpFilter.focus();
5637
5783
  }
5638
5784
 
5785
+ document.getElementById('help-tabs').addEventListener('click', (e) => {
5786
+ const tab = e.target.closest('[data-idx]');
5787
+ if (!tab) return;
5788
+ selectHelpTab(Number(tab.dataset.idx));
5789
+ helpFilter.focus();
5790
+ });
5791
+
5792
+ helpFilter.addEventListener('input', renderHelp);
5793
+
5794
+ // The page's key handler skips text fields, so the filter handles its own Esc and tab keys.
5795
+ helpFilter.addEventListener('keydown', (e) => {
5796
+ if (e.key === 'Escape') {
5797
+ e.preventDefault();
5798
+ e.stopPropagation();
5799
+ if (helpFilter.value) clearHelpFilter();
5800
+ else closeHelpModal();
5801
+ return;
5802
+ }
5803
+ const step = matchKey(e, 'ArrowLeft') ? -1 : matchKey(e, 'ArrowRight') ? 1 : 0;
5804
+ if (!step || helpFilter.value) return;
5805
+ e.preventDefault();
5806
+ const n = helpTabs().length;
5807
+ selectHelpTab((helpTab + step + n) % n);
5808
+ });
5809
+
5639
5810
  function closeHelpModal() {
5640
5811
  const modal = document.getElementById('help-modal');
5641
5812
  modal.classList.remove('visible');
@@ -6275,6 +6446,7 @@ const MODAL_CLOSERS = {
6275
6446
  'agent-modal': () => closeAgentModal(),
6276
6447
  'help-modal': () => closeHelpModal(),
6277
6448
  'session-picker-modal': () => closeSessionPicker(),
6449
+ 'project-picker-modal': () => closeProjectPicker(),
6278
6450
  'terminal-manager-modal': () => closeTerminalManager(),
6279
6451
  'new-session-modal': () => closeNewSession(),
6280
6452
  };
@@ -6395,6 +6567,11 @@ document.addEventListener('keydown', (e) => {
6395
6567
  toggleMessagePanel();
6396
6568
  return;
6397
6569
  }
6570
+ if (e.code === 'KeyP' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
6571
+ e.preventDefault();
6572
+ openProjectPicker();
6573
+ return;
6574
+ }
6398
6575
  if (e.code === 'KeyM' && e.shiftKey) {
6399
6576
  e.preventDefault();
6400
6577
  const msgDetailModal = document.getElementById('msg-detail-modal');
@@ -6562,19 +6739,19 @@ document.addEventListener('keydown', (e) => {
6562
6739
  }
6563
6740
  if (e.key === '$' && !e.ctrlKey && !e.altKey && !e.metaKey) {
6564
6741
  e.preventDefault();
6565
- hubNavigate('cost', contextSid ? `?view=detail&session=${encodeURIComponent(contextSid)}` : undefined);
6742
+ openCost(contextSid);
6566
6743
  return;
6567
6744
  }
6568
6745
  if (matchKey(e, 'KeyM')) {
6569
6746
  e.preventDefault();
6570
6747
  const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
6571
- hubNavigate('marketplace', mSession?.project ? `?project=${encodeURIComponent(mSession.project)}` : undefined);
6748
+ openMarketplace(mSession?.project);
6572
6749
  return;
6573
6750
  }
6574
6751
  if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'm') {
6575
6752
  e.preventDefault();
6576
6753
  const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
6577
- hubNavigate('memory', mSession?.project ? `?project=${encodeURIComponent(mSession.project)}` : undefined);
6754
+ openMemory(mSession?.project);
6578
6755
  return;
6579
6756
  }
6580
6757
  if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
@@ -6608,11 +6785,17 @@ document.addEventListener('keydown', (e) => {
6608
6785
  showToast('No session selected');
6609
6786
  return;
6610
6787
  }
6611
- const text = e.ctrlKey ? `claude -r ${contextSid}` : contextSid;
6612
- const label = e.ctrlKey ? 'resume command' : 'session id';
6788
+ const peerName = e.ctrlKey ? sessions.find((s) => s.id === contextSid)?.peerName : null;
6789
+ const [text, label] = e.ctrlKey
6790
+ ? [peerName && sendMessageAddress(peerName), 'session name']
6791
+ : [contextSid, 'session id'];
6792
+ if (!text) {
6793
+ showToast('No live session name: the session is not running');
6794
+ return;
6795
+ }
6613
6796
  navigator.clipboard
6614
6797
  .writeText(text)
6615
- .then(() => showToast(`Copied ${label}: ${contextSid.slice(0, 8)}`, 'success'))
6798
+ .then(() => showToast(`Copied ${label}: ${e.ctrlKey ? text : text.slice(0, 8)}`, 'success'))
6616
6799
  .catch(() => showToast(`Failed to copy ${label}`));
6617
6800
  return;
6618
6801
  }
@@ -6939,6 +7122,7 @@ function handleSessionOpenEvent(data) {
6939
7122
  stickySessionIds.add(id);
6940
7123
  }
6941
7124
  setSessionDismissed(id, false);
7125
+ cliOpenedId = id;
6942
7126
  fetchTasks(id);
6943
7127
  }
6944
7128
 
@@ -6963,7 +7147,7 @@ function handleDocumentLinkEvent(data) {
6963
7147
  setSessionDocLink(sessionId, filePath, unlink);
6964
7148
  const msg = `${unlink ? 'Unlinked' : 'Linked'} ${linkedDocLabel(filePath)}`;
6965
7149
  // 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) });
7150
+ if (open && isOnScreen()) showToast(msg, 'info', { label: 'Open', onClick: () => hub.openExternal(filePath) });
6967
7151
  else showToast(msg);
6968
7152
  }
6969
7153
 
@@ -7090,7 +7274,7 @@ function linkedDocLabel(p) {
7090
7274
  function openLinkedDoc(p, baseDir) {
7091
7275
  const opener = linkedDocOpener(p);
7092
7276
  if (opener === 'url') {
7093
- openExternal(p);
7277
+ hub.openExternal(p);
7094
7278
  return;
7095
7279
  }
7096
7280
  if (opener === 'preview') {
@@ -7606,7 +7790,7 @@ function setupEventSource() {
7606
7790
  if (data.type === 'dispatch-update') onDispatchUpdate();
7607
7791
 
7608
7792
  if (data.type === 'agent-update') {
7609
- pendingAgentSessionIds.add(data.sessionId);
7793
+ if (!data.unreadOnly) pendingAgentSessionIds.add(data.sessionId);
7610
7794
  clearTimeout(agentRefreshTimer);
7611
7795
  agentRefreshTimer = setTimeout(() => {
7612
7796
  if (skipOffScreen()) return;
@@ -7658,6 +7842,7 @@ function setupEventSource() {
7658
7842
 
7659
7843
  // When cck comes back on screen (tab visible or hub:active), catch up immediately
7660
7844
  async function catchUp() {
7845
+ markOpenSessionRead();
7661
7846
  if (!isOnScreen() || !missedWhileHidden) return;
7662
7847
  missedWhileHidden = false;
7663
7848
  // The full refresh below covers whatever the pending debounces would have fetched.
@@ -7678,12 +7863,11 @@ function setupEventSource() {
7678
7863
  renderAgentFooter();
7679
7864
  }
7680
7865
  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;
7866
+ onHubActive = (active) => {
7867
+ hubActive = active;
7684
7868
  catchUp();
7685
7869
  if (hubActive && wantsTerminal()) onTerminalShown();
7686
- });
7870
+ };
7687
7871
 
7688
7872
  // Fallback poll every 30s in case SSE silently drops; skip when not on screen
7689
7873
  setInterval(() => {
@@ -8680,12 +8864,10 @@ function renderZenState() {
8680
8864
  : 'Zen mode: current session only (Ctrl+Shift+Z)';
8681
8865
  }
8682
8866
 
8683
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
8684
8867
  function filterBySessions(value) {
8685
8868
  if (value === sessionFilter) return;
8686
- sessionFilter = value;
8869
+ setSessionFilter(value);
8687
8870
  resetSessionPage();
8688
- if (value !== 'active') activityFilter.clear();
8689
8871
  renderFilterState();
8690
8872
  updateUrl();
8691
8873
  // Instant feedback from cached data, then refetch — the cached list was fetched
@@ -8696,6 +8878,11 @@ function filterBySessions(value) {
8696
8878
  fetchSessions(false);
8697
8879
  }
8698
8880
 
8881
+ function setSessionFilter(value) {
8882
+ sessionFilter = value;
8883
+ if (value !== 'active') activityFilter.clear();
8884
+ }
8885
+
8699
8886
  function projectFilterText() {
8700
8887
  return filterProject && filterProject !== '__recent__' ? filterProject : '';
8701
8888
  }
@@ -8973,15 +9160,17 @@ const COLOR_THEMES = [
8973
9160
  ['synthwave', "Synthwave '84"],
8974
9161
  ];
8975
9162
 
8976
- function buildThemeMenu() {
9163
+ function buildThemeMenu(themes = COLOR_THEMES) {
8977
9164
  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}"
9165
+ menu.innerHTML = themes
9166
+ .map(
9167
+ ([id, label]) =>
9168
+ `<button type="button" class="theme-menu-item theme-swatch-${id}" data-theme-id="${id}"
8981
9169
  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}
9170
+ <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
9171
  </button>`,
8984
- ).join('');
9172
+ )
9173
+ .join('');
8985
9174
  }
8986
9175
 
8987
9176
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -9241,10 +9430,13 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
9241
9430
  let html = '';
9242
9431
 
9243
9432
  // 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.
9433
+ // Each row: [label, value, { openPath?, abbrev?, copy? }] — `value` is authoritative
9434
+ // (tooltip + copy); `abbrev` only replaces the rendered text, `copy` the copied text.
9246
9435
  const infoRows = [];
9247
9436
  infoRows.push(['Session', session.id, { openClaudeDir: true, openFile: session.jsonlPath }]);
9437
+ if (session.peerName) {
9438
+ infoRows.push(['Peer name', session.peerName, { copy: sendMessageAddress(session.peerName) }]);
9439
+ }
9248
9440
  if (parentInfo?.parentSessionId) {
9249
9441
  infoRows.push([
9250
9442
  parentInfo.relation === 'compact' ? 'Continued from' : 'Forked from',
@@ -9298,7 +9490,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
9298
9490
  } else {
9299
9491
  html += `<span style="${plainStyle}" title="${escapeHtml(value)}">${escapeHtml(opts?.abbrev || value)}</span>`;
9300
9492
  }
9301
- const copyBtn = `<button onclick="copyWithFeedback('${escAttrJs(value)}', this)" title="Copy">${ICON_COPY}</button>`;
9493
+ const copyBtn = `<button onclick="copyWithFeedback('${escAttrJs(opts?.copy || value)}', this)" title="Copy">${ICON_COPY}</button>`;
9302
9494
  let openBtn = '';
9303
9495
  if (opts?.openClaudeDir || opts?.openPath) {
9304
9496
  const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath);
@@ -9393,12 +9585,12 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
9393
9585
  updateStickyBtnState();
9394
9586
  updateDismissBtnState();
9395
9587
  const costBtn = document.getElementById('session-info-cost-btn');
9396
- if (costBtn) costBtn.style.display = window.__HUB__?.enabled || appConfig.costUrl ? '' : 'none';
9588
+ if (costBtn) costBtn.style.display = hub.can('session.cost') ? '' : 'none';
9397
9589
  const mkBtn = document.getElementById('session-info-marketplace-btn');
9398
9590
  const memBtn = document.getElementById('session-info-memory-btn');
9399
9591
  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';
9592
+ if (mkBtn) mkBtn.style.display = proj && hub.can('project.plugins') ? '' : 'none';
9593
+ if (memBtn) memBtn.style.display = proj && hub.can('project.memory') ? '' : 'none';
9402
9594
  modal.classList.add('visible');
9403
9595
 
9404
9596
  if (alreadyVisible) return; // re-render during deferred hydration — key handler already attached
@@ -9593,6 +9785,13 @@ function spDotHtml(session) {
9593
9785
  return isActiveSession(session) ? SP_DOTS.active : SP_DOTS.idle;
9594
9786
  }
9595
9787
 
9788
+ function spPinHtml(session) {
9789
+ const state = getSessionPinState(session.id);
9790
+ if (state === 'sticky') return `<span class="sp-pin sticky" title="Sticky">${SESSION_STAR_SVG}</span>`;
9791
+ if (state === 'pinned') return `<span class="sp-pin pinned" title="Pinned">${SESSION_PIN_SVG}</span>`;
9792
+ return '';
9793
+ }
9794
+
9596
9795
  function renderSessionPicker() {
9597
9796
  const list = document.getElementById('session-picker-list');
9598
9797
  const query = document.getElementById('session-picker-input').value.trim();
@@ -9622,6 +9821,7 @@ function renderSessionPicker() {
9622
9821
  return `<button class="sp-row${s.id === currentSessionId ? ' current' : ''}${outside ? ' outside' : ''}" data-idx="${i}" title="${escapeHtml(s.project ? `${s.id}\n${s.project}` : s.id)}">
9623
9822
  ${spDotHtml(s)}
9624
9823
  <span class="sp-name">${escapeHtml(sessionDisplayName(s))}</span>
9824
+ ${spPinHtml(s)}
9625
9825
  <span class="sp-project">${escapeHtml(project)}</span>
9626
9826
  ${s.gitBranch ? `<span class="sp-branch">${escapeHtml(s.gitBranch)}</span>` : ''}
9627
9827
  <span class="sp-count">${s.completed}/${s.taskCount}</span>
@@ -9685,6 +9885,122 @@ function initSessionPicker() {
9685
9885
  }
9686
9886
  //#endregion
9687
9887
 
9888
+ //#region PROJECT_PICKER
9889
+ // Sets only this board's project filter; a later hub project.changed replaces it.
9890
+ let ppRows = [];
9891
+ let ppIdx = 0;
9892
+ let ppSource = null;
9893
+ let ppRecentOnly = false;
9894
+
9895
+ async function openProjectPicker() {
9896
+ const input = document.getElementById('project-picker-input');
9897
+ input.value = '';
9898
+ renderProjectPicker();
9899
+ document.getElementById('project-picker-modal').classList.add('visible');
9900
+ input.focus();
9901
+ await refreshProjectList();
9902
+ ppSource = (projectsCache || [])
9903
+ .filter((p) => !p.temp)
9904
+ .map((p) => ({ ...p, norm: normalizeProjectPath(p.path) }))
9905
+ .sort((a, b) => (b.modifiedAt || '').localeCompare(a.modifiedAt || ''));
9906
+ renderProjectPicker();
9907
+ }
9908
+
9909
+ function ppToggleRecentOnly() {
9910
+ ppRecentOnly = !ppRecentOnly;
9911
+ document.getElementById('pp-recent-chip').setAttribute('aria-pressed', String(ppRecentOnly));
9912
+ renderProjectPicker();
9913
+ document.getElementById('project-picker-input').focus();
9914
+ }
9915
+
9916
+ // One fetch for both filters: two in a row could land out of order.
9917
+ function ppSetFilters(project, session) {
9918
+ closeProjectPicker();
9919
+ if (project === filterProject) return filterBySessions(session);
9920
+ setSessionFilter(session);
9921
+ filterByProject(project);
9922
+ }
9923
+
9924
+ function closeProjectPicker() {
9925
+ hideModalOverlay('project-picker-modal');
9926
+ }
9927
+
9928
+ function renderProjectPicker() {
9929
+ const list = document.getElementById('project-picker-list');
9930
+ const query = document.getElementById('project-picker-input').value.trim();
9931
+ const source = ppSource || [];
9932
+ const pool = ppRecentOnly ? source.filter((p) => recentProjects.has(p.path)) : source;
9933
+ ppRows = query ? pool.filter((p) => fuzzyMatch(p.path, query)) : pool;
9934
+ if (!ppRows.length) {
9935
+ ppIdx = -1;
9936
+ const empty = ppRecentOnly ? `No project active in the last ${RECENT_PROJECT_HOURS}h` : 'No project matches';
9937
+ list.innerHTML = `<div class="sp-empty">${ppSource ? empty : 'Loading projects…'}</div>`;
9938
+ return;
9939
+ }
9940
+ const current = filterProject && normalizeProjectPath(filterProject);
9941
+ list.innerHTML = ppRows
9942
+ .map((p, i) => {
9943
+ return `<button class="sp-row${p.norm === current ? ' current' : ''}${recentProjects.has(p.path) ? ' recent' : ''}" data-idx="${i}">
9944
+ <span class="sp-name">${escapeHtml(pathBasename(p.path))}</span>
9945
+ <span class="sp-project">${escapeHtml(p.path)}</span>
9946
+ ${p.modifiedAt ? `<span class="sp-time">${formatDate(p.modifiedAt)}</span>` : ''}
9947
+ </button>`;
9948
+ })
9949
+ .join('');
9950
+ const currentIdx = ppRows.findIndex((p) => p.norm === current);
9951
+ ppSelect(query ? 0 : Math.max(0, currentIdx));
9952
+ }
9953
+
9954
+ function ppSelect(idx) {
9955
+ const list = document.getElementById('project-picker-list');
9956
+ list.children[ppIdx]?.classList.remove('selected');
9957
+ ppIdx = Math.min(Math.max(idx, 0), ppRows.length - 1);
9958
+ const row = list.children[ppIdx];
9959
+ row?.classList.add('selected');
9960
+ row?.scrollIntoView({ block: 'nearest' });
9961
+ }
9962
+
9963
+ function ppApply(idx) {
9964
+ const row = ppRows[idx];
9965
+ if (!row) return;
9966
+ ppSetFilters(row.path, ppRecentOnly ? 'active' : 'all');
9967
+ }
9968
+
9969
+ function initProjectPicker() {
9970
+ const input = document.getElementById('project-picker-input');
9971
+ input.addEventListener('input', renderProjectPicker);
9972
+ input.addEventListener('keydown', (e) => {
9973
+ if (e.key === 'Escape') closeProjectPicker();
9974
+ else if (matchKey(e, 'ArrowDown') || (e.ctrlKey && e.key === 'n')) ppSelect(ppIdx + 1);
9975
+ else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && !e.shiftKey && e.key === 'p')) ppSelect(ppIdx - 1);
9976
+ else if (e.key === 'Enter') ppApply(ppIdx);
9977
+ else if (e.altKey && !e.ctrlKey && !e.metaKey && e.code === 'KeyR') {
9978
+ if (!e.repeat) ppToggleRecentOnly();
9979
+ } else if (e.altKey && !e.ctrlKey && !e.metaKey && e.code === 'KeyC')
9980
+ ppSetFilters(FILTER_DEFAULTS.project, FILTER_DEFAULTS.session);
9981
+ else return;
9982
+ e.preventDefault();
9983
+ e.stopPropagation();
9984
+ });
9985
+ document.getElementById('project-picker-list').addEventListener('click', (e) => {
9986
+ const row = e.target.closest('.sp-row');
9987
+ if (row) ppApply(Number(row.dataset.idx));
9988
+ });
9989
+ // Vimium's Escape only blurs the input; see initSessionPicker.
9990
+ const modal = document.getElementById('project-picker-modal');
9991
+ let pointerDown = false;
9992
+ modal.addEventListener('mousedown', () => {
9993
+ pointerDown = true;
9994
+ });
9995
+ document.addEventListener('mouseup', () => {
9996
+ pointerDown = false;
9997
+ });
9998
+ input.addEventListener('blur', () => {
9999
+ if (!pointerDown && modal.classList.contains('visible') && document.hasFocus()) closeProjectPicker();
10000
+ });
10001
+ }
10002
+ //#endregion
10003
+
9688
10004
  //#region PLAN
9689
10005
  function refreshOpenPlan() {
9690
10006
  if (!_planSessionId || !document.getElementById('plan-modal').classList.contains('visible')) return;
@@ -10062,35 +10378,16 @@ function openFolderInEditor(folder, file) {
10062
10378
  postAndToast('/api/open-folder', body, 'folder');
10063
10379
  }
10064
10380
 
10065
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
10066
10381
  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
- }
10382
+ hub.invoke('session.cost', sessionId ? { session: sessionId } : {});
10072
10383
  }
10073
10384
 
10074
10385
  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
- }
10386
+ hub.invoke('project.plugins', projectPath ? { project: projectPath } : {});
10083
10387
  }
10084
10388
 
10085
10389
  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
- }
10390
+ hub.invoke('project.memory', projectPath ? { project: projectPath } : {});
10094
10391
  }
10095
10392
 
10096
10393
  function openForInfoModalProject(open) {
@@ -10262,10 +10559,9 @@ function filterByOwner(value) {
10262
10559
  // leaving the session view or switching sessions drops only the socket; coming back
10263
10560
  // reattaches and the server replays the screen.
10264
10561
  const TERMINAL_TOKEN_KEY = 'terminal-token';
10562
+ // Every config dir's board is served on the hub's one kanban origin, so sessionStorage is shared.
10563
+ const TERMINAL_FOCUS_KEY = nsKey('terminal-focus');
10265
10564
  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
10565
  const TERMINAL_MODES_KEY = 'terminal-sessions';
10270
10566
  const TERMINAL_FONT_KEY = 'terminal-font-size';
10271
10567
  const TERMINAL_FONT_MIN = 8;
@@ -10322,32 +10618,18 @@ let tokenRefresh = null;
10322
10618
  // The hub mints a new token each run, and this page can outlive a hub restart. Resolves true only
10323
10619
  // for a token that differs from the one that just failed, so a caller retries at most once.
10324
10620
  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);
10621
+ tokenRefresh ??= hub
10622
+ .terminalToken()
10623
+ .then((token) => {
10624
+ if (!TERMINAL_TOKEN_RE.test(token) || token === terminalToken) return false;
10343
10625
  terminalToken = token;
10344
10626
  storeTerminalToken(token);
10345
- done(true);
10346
- };
10347
- const timer = setTimeout(() => done(false), 3000);
10348
- window.addEventListener('message', onMessage);
10349
- hubPost({ type: 'hub:terminalToken' });
10350
- });
10627
+ return true;
10628
+ })
10629
+ .finally(() => {
10630
+ tokenRefresh = null;
10631
+ });
10632
+ return tokenRefresh;
10351
10633
  }
10352
10634
 
10353
10635
  function terminalAvailable() {
@@ -10384,6 +10666,11 @@ function terminalPaneFocused() {
10384
10666
 
10385
10667
  const TEXT_FIELD_SELECTOR = 'input, textarea, select, [contenteditable]';
10386
10668
 
10669
+ function inPageField(e) {
10670
+ const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
10671
+ return !!field && !document.getElementById('terminal-pane').contains(field);
10672
+ }
10673
+
10387
10674
  function terminalShortcut(e) {
10388
10675
  const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
10389
10676
  const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
@@ -10393,13 +10680,11 @@ function terminalShortcut(e) {
10393
10680
  return () => adjustTerminalFontSize(Math.sign(zoom));
10394
10681
  }
10395
10682
  if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
10683
+ if (matchKey(e, 'KeyR') && terminalConnectSlow() && wantsTerminal() && !inPageField(e)) return reconnectTerminal;
10396
10684
  // Cancelling the default also stops Chrome's system print dialog, which this page has no use for.
10397
10685
  if (ctrlShift && e.code === 'KeyP') return toggleSessionPicker;
10398
10686
  // 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
- }
10687
+ if (ctrlShift && e.code === 'KeyZ') return inPageField(e) ? null : toggleZenMode;
10403
10688
  if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
10404
10689
  return () => openNewSession(null, e.code === 'KeyR');
10405
10690
  }
@@ -10493,6 +10778,7 @@ function openSession(sessionId) {
10493
10778
  const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
10494
10779
  if (!alreadyOpen) termState.openedByUser = sessionId;
10495
10780
  else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
10781
+ markSessionRead(sessionId);
10496
10782
  return fetchTasks(sessionId);
10497
10783
  }
10498
10784
 
@@ -10595,7 +10881,7 @@ function terminalKeyFilter(e) {
10595
10881
  return false;
10596
10882
  }
10597
10883
  if (ctrlOnly && e.code === 'KeyV') return false;
10598
- return !isHubKey(e);
10884
+ return !hub.forwards(e);
10599
10885
  }
10600
10886
 
10601
10887
  function oscColor(hex) {
@@ -10765,22 +11051,44 @@ function hideTerminalPrompt() {
10765
11051
 
10766
11052
  const TERMINAL_BOOT_VERBS = ['Waking Claude…', 'Clawding…', 'Warming up the terminal…', 'Summoning the session…'];
10767
11053
  const TERMINAL_BOOT_MAX_MS = 15000;
11054
+ // The server answers `ready` as soon as the socket attaches, even before Claude draws, so a wait
11055
+ // this long is the connection: under the hub a loopback connect can hang until its timeout.
11056
+ const TERMINAL_SLOW_CONNECT_MS = 1500;
10768
11057
  let terminalBootTimer = null;
11058
+ let terminalSlowTimer = null;
10769
11059
 
10770
11060
  // A new PTY shows only a blinking cursor until Claude draws its first frame.
10771
11061
  function showTerminalBoot() {
10772
11062
  document.getElementById('terminal-boot-verb').textContent = pickRandom(TERMINAL_BOOT_VERBS);
10773
11063
  document.getElementById('terminal-boot').classList.add('visible');
10774
11064
  clearTimeout(terminalBootTimer);
10775
- terminalBootTimer = setTimeout(hideTerminalBoot, TERMINAL_BOOT_MAX_MS);
11065
+ terminalBootTimer = setTimeout(() => {
11066
+ if (!terminalConnectSlow()) hideTerminalBoot();
11067
+ }, TERMINAL_BOOT_MAX_MS);
10776
11068
  }
10777
11069
 
10778
11070
  function hideTerminalBoot() {
10779
11071
  clearTimeout(terminalBootTimer);
10780
11072
  terminalBootTimer = null;
11073
+ clearTerminalSlow();
10781
11074
  document.getElementById('terminal-boot').classList.remove('visible');
10782
11075
  }
10783
11076
 
11077
+ function clearTerminalSlow() {
11078
+ clearTimeout(terminalSlowTimer);
11079
+ terminalSlowTimer = null;
11080
+ document.getElementById('terminal-boot').classList.remove('slow');
11081
+ }
11082
+
11083
+ function terminalConnectSlow() {
11084
+ return document.getElementById('terminal-boot').classList.contains('slow');
11085
+ }
11086
+
11087
+ function reconnectTerminal() {
11088
+ const id = termState.sessionId;
11089
+ if (id) openTerminal(id, terminalOpenMode(id));
11090
+ }
11091
+
10784
11092
  function showTerminalPrompt(sessionId, title, detail, choices, output = '') {
10785
11093
  const el = document.getElementById('terminal-prompt');
10786
11094
  const tail = output ? `<pre class="${TINTED_PRE_CLASS} terminal-prompt-output">${escapeHtml(output)}</pre>` : '';
@@ -10821,13 +11129,41 @@ function syncCloseGuard() {
10821
11129
  const on = termState.attached && termState.shown;
10822
11130
  if (termState.closeGuard === on) return;
10823
11131
  termState.closeGuard = on;
10824
- hubPost({ type: 'hub:closeGuard', on });
11132
+ hub.closeGuard(on);
10825
11133
  }
10826
11134
 
10827
11135
  window.addEventListener('beforeunload', (e) => {
10828
- if (termState.closeGuard && !window.__HUB__?.enabled) e.preventDefault();
11136
+ if (termState.closeGuard && !hub.inHub) e.preventDefault();
11137
+ });
11138
+
11139
+ // A config-dir switch in the hub reloads this page, and coming back should put the cursor where it was.
11140
+ // The hub's palette takes focus first and clears activeElement here, so the last focus inside this
11141
+ // page is tracked instead; focus leaving the frame keeps it.
11142
+ let terminalHadFocus = false;
11143
+ document.addEventListener('focusin', () => {
11144
+ terminalHadFocus = terminalPaneFocused();
11145
+ });
11146
+ document.addEventListener('focusout', (e) => {
11147
+ if (e.relatedTarget) return;
11148
+ setTimeout(() => {
11149
+ if (document.hasFocus()) terminalHadFocus = terminalPaneFocused();
11150
+ });
11151
+ });
11152
+ window.addEventListener('pagehide', () => {
11153
+ try {
11154
+ if (termState.attached && termState.shown && terminalHadFocus) {
11155
+ sessionStorage.setItem(TERMINAL_FOCUS_KEY, currentSessionId);
11156
+ } else sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
11157
+ } catch (_) {}
10829
11158
  });
10830
11159
 
11160
+ function restoreTerminalFocus(sessionId) {
11161
+ try {
11162
+ if (sessionStorage.getItem(TERMINAL_FOCUS_KEY) === sessionId) termState.focusNext = true;
11163
+ sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
11164
+ } catch (_) {}
11165
+ }
11166
+
10831
11167
  // The terminal stands in for the board, so it takes the board zone however focus arrives
10832
11168
  // (Tab, Alt+`, a click); leaveTerminalPane hands it back to the sidebar.
10833
11169
  document.getElementById('terminal-pane').addEventListener('focusin', () => {
@@ -10876,6 +11212,11 @@ async function openTerminal(sessionId, mode, attempt = 0) {
10876
11212
  const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/terminal/ws`);
10877
11213
  ws.binaryType = 'arraybuffer';
10878
11214
  termState.ws = ws;
11215
+ if (!attempt) {
11216
+ terminalSlowTimer = setTimeout(() => {
11217
+ if (termState.ws === ws) document.getElementById('terminal-boot').classList.add('slow');
11218
+ }, TERMINAL_SLOW_CONNECT_MS);
11219
+ }
10879
11220
  const spec = newSpecs.get(sessionId);
10880
11221
  let readyAt = 0;
10881
11222
  let refused = false;
@@ -10978,6 +11319,7 @@ function onTerminalMessage(sessionId, msg) {
10978
11319
  // The server has the prompt now; a later start of the same placeholder must not send it again.
10979
11320
  const spec = newSpecs.get(sessionId);
10980
11321
  if (spec) spec.prompt = null;
11322
+ clearTerminalSlow();
10981
11323
  hideTerminalPrompt();
10982
11324
  setTerminalStatus('');
10983
11325
  setTerminalAttached(true);
@@ -11270,6 +11612,7 @@ async function loadTerminals() {
11270
11612
  const res = await fetch('/api/terminals', { cache: 'no-store' });
11271
11613
  const list = (await res.json()).sessions || [];
11272
11614
  runningTerminals = new Set(list.map((t) => t.id));
11615
+ renderActivityChip();
11273
11616
  return list;
11274
11617
  }
11275
11618
 
@@ -11277,6 +11620,7 @@ function setRunningTerminals(ids) {
11277
11620
  runningTerminals = new Set(ids);
11278
11621
  if (ids.some((id) => !sessions.some((s) => s.id === id))) fetchSessions(false).catch(() => {});
11279
11622
  else renderSessions();
11623
+ renderActivityChip();
11280
11624
  }
11281
11625
 
11282
11626
  function showSessionTerminal(sessionId) {
@@ -11637,6 +11981,7 @@ try {
11637
11981
  loadSessionGroups();
11638
11982
  initSessionGroupsDnd();
11639
11983
  initSessionPicker();
11984
+ initProjectPicker();
11640
11985
  try {
11641
11986
  const af = JSON.parse(store.getItem('activityFilter') || '[]');
11642
11987
  // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
@@ -11828,21 +12173,12 @@ if (urlState.search) {
11828
12173
  document.getElementById('boot-verb').textContent = pickRandom(verbs);
11829
12174
  }
11830
12175
 
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
- ])
12176
+ fetch('/api/config')
12177
+ .then((r) => r.json())
12178
+ .then((c) => {
12179
+ appConfig = c;
12180
+ })
12181
+ .catch(() => {})
11846
12182
  .then(restorePendingSessions)
11847
12183
  .then(() => fetchSessions())
11848
12184
  .then(async () => {
@@ -11853,6 +12189,7 @@ Promise.all([
11853
12189
  showNoSession();
11854
12190
  }
11855
12191
  } else if (urlState.session) {
12192
+ restoreTerminalFocus(urlState.session);
11856
12193
  await fetchTasks(urlState.session);
11857
12194
  } else {
11858
12195
  const last = loadLastView();
@@ -11863,6 +12200,7 @@ Promise.all([
11863
12200
  showNoSession();
11864
12201
  }
11865
12202
  } else if (last?.view === 'session' && last.session && sessions.some((s) => s.id === last.session)) {
12203
+ restoreTerminalFocus(last.session);
11866
12204
  await fetchTasks(last.session);
11867
12205
  } else {
11868
12206
  showNoSession();
@@ -11906,49 +12244,65 @@ window.addEventListener('popstate', () => {
11906
12244
  //#endregion
11907
12245
 
11908
12246
  // #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;
12247
+ const costDetail = (session) => `?view=detail&session=${encodeURIComponent(session)}`;
12248
+ const projectQuery = (project) => `?${new URLSearchParams({ project })}`;
12249
+ const hub = ClaudeHub.connect({
12250
+ // A function, because the --*-url flags arrive with /api/config after connect.
12251
+ standalone: () => ({
12252
+ ...(appConfig.costUrl && {
12253
+ 'session.cost': (p) => p.session && new URL(costDetail(p.session), appConfig.costUrl).href,
12254
+ }),
12255
+ ...(appConfig.marketplaceUrl && {
12256
+ 'project.plugins': (p) => p.project && new URL(projectQuery(p.project), appConfig.marketplaceUrl).href,
12257
+ }),
12258
+ ...(appConfig.memoryUrl && {
12259
+ 'project.memory': (p) => p.project && new URL(projectQuery(p.project), appConfig.memoryUrl).href,
12260
+ }),
12261
+ }),
12262
+ });
11934
12263
 
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('+');
12264
+ hub.onActive((active) => onHubActive(active));
12265
+
12266
+ // session.changed goes out once the switch settles, so arrowing through the list sends one event.
12267
+ let publishTimer = 0;
12268
+ let publishedSessionId;
12269
+ let cliOpenedId = null;
12270
+
12271
+ function schedulePublishSession() {
12272
+ clearTimeout(publishTimer);
12273
+ publishTimer = setTimeout(publishSession, 150);
12274
+ }
12275
+
12276
+ function publishSession() {
12277
+ if (currentSessionId === publishedSessionId) return;
12278
+ const s = currentSessionId ? sessions.find((x) => x.id === currentSessionId) : null;
12279
+ // A placeholder publishes once its real row replaces it.
12280
+ if (currentSessionId && (!s || s.placeholder)) return;
12281
+ publishedSessionId = currentSessionId;
12282
+ const source = currentSessionId === cliOpenedId ? 'cli' : 'user';
12283
+ cliOpenedId = null;
12284
+ hub.publish(
12285
+ 'session.changed',
12286
+ s && {
12287
+ sessionId: s.id,
12288
+ project: s.project,
12289
+ name: s.name || null,
12290
+ gitBranch: s.gitBranch || null,
12291
+ live: isSessionActive(s),
12292
+ source,
12293
+ },
12294
+ );
11942
12295
  }
11943
12296
 
11944
- document.addEventListener('keydown', (e) => {
11945
- if (!isHubKey(e)) return;
12297
+ // The help modal stops click propagation, so the document handler below never sees this link.
12298
+ // The service worker can pair a cached page that lacks the link with this script.
12299
+ document.getElementById('help-docs')?.addEventListener('click', (e) => {
11946
12300
  e.preventDefault();
11947
- hubPost({ type: 'hub:keydown', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
12301
+ hub.openExternal(e.currentTarget.href);
11948
12302
  });
11949
12303
 
11950
12304
  document.addEventListener('click', (e) => {
11951
- if (!window.__HUB__?.enabled) return;
12305
+ if (!hub.inHub) return;
11952
12306
  const a = e.target.closest?.('a[href]');
11953
12307
  if (!a) return;
11954
12308
  const href = a.getAttribute('href');
@@ -11963,78 +12317,34 @@ document.addEventListener('click', (e) => {
11963
12317
  if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
11964
12318
  e.preventDefault();
11965
12319
  e.stopPropagation();
11966
- hubPost({ type: 'hub:openExternal', url: url.href });
12320
+ hub.openExternal(url.href);
11967
12321
  });
11968
12322
 
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
12323
  (function initHubTheme() {
11992
12324
  const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
11993
12325
  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
- }
12326
+ const report = hub.bindTheme({
12327
+ get: () => ({ theme: getTheme(), colorTheme: getColorTheme() }),
12328
+ set: ({ theme, colorTheme }) => {
12329
+ if (colorTheme !== getColorTheme()) setColorTheme(colorTheme);
12330
+ if (theme !== getTheme()) window.toggleTheme();
12331
+ },
12009
12332
  });
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, {
12333
+ new MutationObserver(report).observe(document.body, {
12018
12334
  attributes: true,
12019
12335
  attributeFilter: ['class', 'data-color-theme'],
12020
12336
  });
12021
12337
  })();
12022
12338
 
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
- })();
12339
+ // Under the hub the picker lists the hub's themes, which include the user's own (protocol section 6, rule 5).
12340
+ hub.onThemes((themes) => {
12341
+ buildThemeMenu(themes.map((t) => [t.id, t.label]));
12342
+ syncColorThemeSelect(document.body.dataset.colorTheme);
12343
+ });
12030
12344
 
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
- })();
12345
+ hub.subscribe('project.changed', (p) => {
12346
+ const dirPath = p?.project;
12347
+ if (typeof dirPath !== 'string' || !dirPath || dirPath === filterProject) return;
12348
+ filterByProject(dirPath);
12349
+ });
12040
12350
  // #endregion HUB_INTEGRATION