claude-code-hub 1.36.0 → 1.37.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "1.36.0",
3
+ "version": "1.37.0",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -47,7 +47,7 @@
47
47
  "homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
48
48
  "dependencies": {
49
49
  "claude-code-cost": "1.6.2",
50
- "claude-code-kanban": "7.33.0",
50
+ "claude-code-kanban": "7.34.0",
51
51
  "claude-code-marketplace": "1.7.3",
52
52
  "claude-code-memory-explorer": "1.3.6",
53
53
  "express": "^4.21.0",
package/public/app.js CHANGED
@@ -249,6 +249,7 @@ function onPublish(appId, { topic, payload }) {
249
249
  ? { ...payload, encoded: encodeProjectPath(payload.project), projectName: basename(payload.project) }
250
250
  : payload;
251
251
  published.set(topic, { from: appId, payload: event });
252
+ if (topic === 'attention.changed') onAttention(appId, event);
252
253
  for (const id of Object.keys(iframes)) {
253
254
  if (id === appId) continue;
254
255
  if (id === activeApp) sendTopic(id, topic, event);
@@ -317,6 +318,7 @@ async function init() {
317
318
  bindSetup();
318
319
  registerSW();
319
320
  bindErrorToast();
321
+ bindAttentionToast();
320
322
  showUpdateToast();
321
323
  if (!config.onboarding) maybeShowSetup();
322
324
  reloadOnHubRestart();
@@ -1010,8 +1012,108 @@ function bindErrorToast() {
1010
1012
  // The hub has no visible chrome, so the window/tab title is the only place the active config dir
1011
1013
  // shows. Only a non-default dir is named, and by basename alone — the PWA window prepends the
1012
1014
  // manifest name, so a product name here would read as "Claude Code Hub - .claude-eom · Hub".
1013
- function applyTitle(dir) {
1014
- document.title = dir && dir !== defaultConfigDir ? basename(dir) : 'Claude Code Hub';
1015
+ function applyTitle(dir = titleDir) {
1016
+ titleDir = dir;
1017
+ const name = dir && dir !== defaultConfigDir ? basename(dir) : 'Claude Code Hub';
1018
+ const n = attentionCount();
1019
+ document.title = n ? `(${n}) ${name}` : name;
1020
+ }
1021
+
1022
+ // attention.changed lists the sessions whose terminal reports blocked. With no tab strip, the
1023
+ // count goes in the window title and the installed app's taskbar badge. A session that newly
1024
+ // blocks while its app is off screen gets a toast, at most once per ATTENTION_GAP_MS each,
1025
+ // and none from the first payload, so a reload does not toast what was already waiting. The
1026
+ // toast goes when its app comes on screen or the session stops waiting.
1027
+ const ATTENTION_KINDS = { permission: 'Permission', question: 'Question', auth: 'Sign-in' };
1028
+ const ATTENTION_ICONS = {
1029
+ permission: [['path', { d: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }]],
1030
+ question: [
1031
+ ['circle', { cx: 12, cy: 12, r: 10 }],
1032
+ ['path', { d: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3' }],
1033
+ ['path', { d: 'M12 17h.01' }],
1034
+ ],
1035
+ auth: [
1036
+ ['circle', { cx: 7.5, cy: 15.5, r: 5.5 }],
1037
+ ['path', { d: 'm21 2-9.6 9.6' }],
1038
+ ['path', { d: 'm15.5 7.5 3 3L22 7l-3-3' }],
1039
+ ],
1040
+ other: [
1041
+ ['path', { d: 'M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9' }],
1042
+ ['path', { d: 'M10.3 21a1.94 1.94 0 0 0 3.4 0' }],
1043
+ ],
1044
+ };
1045
+ const ATTENTION_TOAST_MS = 8000;
1046
+ const ATTENTION_GAP_MS = 30_000;
1047
+ let titleDir = null;
1048
+ const attention = new Map();
1049
+ const attentionToasted = new Map();
1050
+ let attentionTimer = 0;
1051
+
1052
+ const attentionCount = () => [...attention.values()].reduce((n, list) => n + list.length, 0);
1053
+
1054
+ function onAttention(appId, payload) {
1055
+ const list = Array.isArray(payload?.blocked) ? payload.blocked : [];
1056
+ const blocked = list
1057
+ .filter((b) => isPlainObject(b) && typeof b.id === 'string' && b.id.length <= 64 && typeof b.name === 'string')
1058
+ .slice(0, 20)
1059
+ .map((b) => ({
1060
+ id: b.id,
1061
+ name: b.name.slice(0, 120),
1062
+ kind: Object.hasOwn(ATTENTION_KINDS, b.kind) ? b.kind : null,
1063
+ }));
1064
+ const seeded = attention.has(appId);
1065
+ const before = new Set(attention.get(appId)?.map((b) => b.id));
1066
+ attention.set(appId, blocked);
1067
+ applyTitle();
1068
+ const total = attentionCount();
1069
+ if (total) navigator.setAppBadge?.(total).catch(() => {});
1070
+ else navigator.clearAppBadge?.().catch(() => {});
1071
+ const now = Date.now();
1072
+ for (const [id, at] of attentionToasted) if (now - at > ATTENTION_GAP_MS) attentionToasted.delete(id);
1073
+ const fresh = blocked.find((b) => !before.has(b.id) && !attentionToasted.has(b.id));
1074
+ const toast = document.getElementById('attention-toast');
1075
+ if (toast.dataset.app === appId && !blocked.some((b) => b.id === toast.dataset.session)) hideAttentionToast();
1076
+ if (!seeded || !fresh || appId === activeApp) return;
1077
+ attentionToasted.set(fresh.id, now);
1078
+ showAttentionToast(appId, fresh, blocked.length - 1);
1079
+ }
1080
+
1081
+ function hideAttentionToast() {
1082
+ clearTimeout(attentionTimer);
1083
+ const toast = document.getElementById('attention-toast');
1084
+ if (!toast.hidden) hideToast(toast);
1085
+ }
1086
+
1087
+ function showAttentionToast(appId, b, others) {
1088
+ const toast = document.getElementById('attention-toast');
1089
+ document
1090
+ .getElementById('attention-icon')
1091
+ .replaceChildren(lucideSvg(ATTENTION_ICONS[b.kind] ?? ATTENTION_ICONS.other));
1092
+ document.getElementById('attention-title').textContent = b.name;
1093
+ document.getElementById('attention-kind').textContent = ATTENTION_KINDS[b.kind] ?? 'Needs you';
1094
+ document.getElementById('attention-detail').textContent = `waiting in ${apps[appId]?.name ?? appId}`;
1095
+ document.getElementById('attention-more').textContent = others > 0 ? `+${others} more waiting` : '';
1096
+ toast.dataset.app = appId;
1097
+ toast.dataset.session = b.id;
1098
+ const timer = document.getElementById('attention-timer');
1099
+ timer.style.animation = 'none';
1100
+ void timer.offsetWidth;
1101
+ timer.style.animation = '';
1102
+ clearTimeout(attentionTimer);
1103
+ attentionTimer = setTimeout(hideAttentionToast, ATTENTION_TOAST_MS);
1104
+ showToast(toast);
1105
+ }
1106
+
1107
+ function bindAttentionToast() {
1108
+ const toast = document.getElementById('attention-toast');
1109
+ toast.style.setProperty('--attention-ms', `${ATTENTION_TOAST_MS}ms`);
1110
+ toast.addEventListener('mouseenter', () => clearTimeout(attentionTimer));
1111
+ toast.addEventListener('mouseleave', () => (attentionTimer = setTimeout(hideAttentionToast, ATTENTION_TOAST_MS / 2)));
1112
+ document.getElementById('attention-close').addEventListener('click', hideAttentionToast);
1113
+ document.getElementById('attention-open').addEventListener('click', () => {
1114
+ hideAttentionToast();
1115
+ onInvoke({ id: 'attention', action: 'session.open', params: { session: toast.dataset.session } }, () => {});
1116
+ });
1015
1117
  }
1016
1118
 
1017
1119
  // After a config-dir switch every child is a new process, possibly on a new port. Reloading each
@@ -1131,6 +1233,7 @@ function switchTab(appId) {
1131
1233
  if (!iframes[appId]) addIframe(appId);
1132
1234
  if (appId !== activeApp) previousApp = activeApp;
1133
1235
  activeApp = appId;
1236
+ if (document.getElementById('attention-toast').dataset.app === appId) hideAttentionToast();
1134
1237
  try {
1135
1238
  localStorage.setItem(TAB_KEY, appId);
1136
1239
  } catch {}
package/public/index.html CHANGED
@@ -654,6 +654,77 @@
654
654
  }
655
655
 
656
656
  .update-toast[hidden] { display: none; }
657
+ .attention-toast {
658
+ width: min(380px, calc(100vw - 32px));
659
+ padding: 14px 14px 16px;
660
+ gap: 12px;
661
+ overflow: hidden;
662
+ border-left-color: var(--warn);
663
+ animation: attention-in 0.22s ease-out;
664
+ }
665
+ .attention-icon {
666
+ flex: none;
667
+ display: grid;
668
+ place-items: center;
669
+ width: 34px;
670
+ height: 34px;
671
+ border-radius: 50%;
672
+ background: color-mix(in srgb, var(--warn) 16%, transparent);
673
+ color: var(--warn);
674
+ }
675
+ .attention-icon svg { width: 18px; height: 18px; }
676
+ .attention-name {
677
+ font: 600 14px/1.35 system-ui, -apple-system, "Segoe UI", sans-serif;
678
+ overflow: hidden;
679
+ white-space: nowrap;
680
+ text-overflow: ellipsis;
681
+ }
682
+ .attention-meta {
683
+ display: flex;
684
+ align-items: center;
685
+ gap: 6px;
686
+ margin-top: 3px;
687
+ color: var(--text-dim);
688
+ font-size: 12px;
689
+ }
690
+ .attention-kind {
691
+ padding: 0 7px;
692
+ border: 1px solid currentColor;
693
+ border-radius: 9px;
694
+ color: var(--warn);
695
+ font-size: 11px;
696
+ font-weight: 600;
697
+ line-height: 16px;
698
+ }
699
+ .attention-actions { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
700
+ .attention-open {
701
+ padding: 5px 12px;
702
+ border: none;
703
+ border-radius: 6px;
704
+ background: var(--accent);
705
+ color: #fff;
706
+ font: 600 12px system-ui, -apple-system, "Segoe UI", sans-serif;
707
+ cursor: pointer;
708
+ }
709
+ .attention-open:hover { filter: brightness(1.08); }
710
+ .attention-more { color: var(--text-dim); font-size: 12px; }
711
+ .attention-timer {
712
+ position: absolute;
713
+ left: 0;
714
+ right: 0;
715
+ bottom: 0;
716
+ height: 2px;
717
+ background: var(--warn);
718
+ opacity: 0.5;
719
+ transform-origin: left;
720
+ animation: attention-timer var(--attention-ms, 8s) linear forwards;
721
+ }
722
+ .attention-toast:hover .attention-timer { animation-play-state: paused; }
723
+ @keyframes attention-in { from { opacity: 0; transform: translateY(8px); } }
724
+ @keyframes attention-timer { to { transform: scaleX(0); } }
725
+ @media (prefers-reduced-motion: reduce) {
726
+ .attention-toast, .attention-timer { animation: none; }
727
+ }
657
728
  .voice-pill {
658
729
  position: fixed;
659
730
  left: 50%;
@@ -873,6 +944,20 @@
873
944
  <button class="update-toast-close" id="update-close" type="button" aria-label="Close">×</button>
874
945
  </div>
875
946
 
947
+ <div class="update-toast attention-toast" id="attention-toast" role="status" hidden>
948
+ <span class="attention-icon" id="attention-icon" aria-hidden="true"></span>
949
+ <div class="update-toast-body">
950
+ <div class="attention-name" id="attention-title"></div>
951
+ <div class="attention-meta"><span class="attention-kind" id="attention-kind"></span><span id="attention-detail"></span></div>
952
+ <div class="attention-actions">
953
+ <button class="attention-open" id="attention-open" type="button">Open session</button>
954
+ <span class="attention-more" id="attention-more"></span>
955
+ </div>
956
+ </div>
957
+ <button class="update-toast-close" id="attention-close" type="button" aria-label="Close">×</button>
958
+ <span class="attention-timer" id="attention-timer"></span>
959
+ </div>
960
+
876
961
  <div class="update-toast error-toast" id="error-toast" role="alert" hidden>
877
962
  <div class="update-toast-body">
878
963
  <div class="update-toast-title" id="error-title"></div>
package/server.js CHANGED
@@ -330,6 +330,11 @@ function childEnv(pool, name) {
330
330
  const mods = appModEnv(MODS, name);
331
331
  if (mods) env.HUB_MODS = mods;
332
332
  else delete env.HUB_MODS;
333
+ // A setting the app's server needs before any page connects, such as cck's terminal host for
334
+ // the terminals it restores at start. A change later reaches the app through its pages.
335
+ const settings = appValues.get(name);
336
+ if (settings) env.HUB_APP_SETTINGS = JSON.stringify(settings);
337
+ else delete env.HUB_APP_SETTINGS;
333
338
  if (TERMINAL.enabled && name === TERMINAL_APP.id) {
334
339
  env.CCK_TERMINAL = JSON.stringify(TERMINAL);
335
340
  env.CCK_TERMINAL_TOKEN = TERMINAL_TOKEN;