@bakapiano/ccsm 0.20.0 → 0.20.1

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/lib/tunnel.js CHANGED
@@ -288,13 +288,39 @@ async function probe(force = false) {
288
288
  return probeCache;
289
289
  }
290
290
 
291
+ // Kick a single background probe refresh, deduped so overlapping callers
292
+ // share one shell-out. Never throws.
293
+ let probeRefreshing = null;
294
+ function kickProbeRefresh() {
295
+ if (!probeRefreshing) {
296
+ probeRefreshing = probe(true)
297
+ .catch(() => probeCache)
298
+ .finally(() => { probeRefreshing = null; });
299
+ }
300
+ return probeRefreshing;
301
+ }
302
+
291
303
  // Invalidate the cache when callers know the on-disk state likely changed
292
- // (post-install, post-login, etc.). Next probe() re-shells.
293
- function invalidateProbe() { probeCache = null; probeCacheAt = 0; }
304
+ // (post-install, post-login, etc.) and immediately start repopulating it
305
+ // in the background so the next status poll is already fresh.
306
+ function invalidateProbe() { probeCache = null; probeCacheAt = 0; kickProbeRefresh(); }
307
+
308
+ // Stale-while-revalidate accessor used by status(). NEVER shells out in
309
+ // the request path: returns whatever's cached right now (possibly stale,
310
+ // or null on the very first call before the boot prewarm lands) and kicks
311
+ // off a background refresh when the cache is stale. This is what keeps
312
+ // /api/tunnel/status — and therefore the whole Remote page's live refresh
313
+ // (plus the device list, which the client used to bundle into the same
314
+ // round-trip) — from stalling ~700ms every time the 30s cache expires.
315
+ function probeCachedSWR() {
316
+ const fresh = probeCache && Date.now() - probeCacheAt < PROBE_TTL_MS;
317
+ if (!fresh) kickProbeRefresh();
318
+ return probeCache;
319
+ }
294
320
 
295
321
  async function status() {
296
322
  return {
297
- providers: await probe(),
323
+ providers: probeCachedSWR(),
298
324
  running: !!current,
299
325
  provider: current?.provider || null,
300
326
  url: current?.url || null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -22,6 +22,10 @@
22
22
  border-color: #ffffff;
23
23
  box-shadow: 0 4px 14px -4px rgba(0, 0, 0, 0.6);
24
24
  }
25
+ /* .fab base is var(--ink) (a light slab in dark mode) with var(--bg-elev)
26
+ text; its hover hardcoded #000, which would render dark text on black.
27
+ Send the hover lighter instead, matching .action.primary. */
28
+ [data-theme="dark"] .fab:hover { background: #ffffff; }
25
29
  /* Focus rings / hover shadows used a dark ink wash that vanishes on a dark
26
30
  ground — switch to a light wash so the affordance stays visible. */
27
31
  [data-theme="dark"] .action:hover { box-shadow: 0 2px 4px -2px rgba(0, 0, 0, 0.5); }
@@ -202,39 +202,50 @@
202
202
  text-align: center;
203
203
  }
204
204
 
205
- /* RestartOverlay — small top-of-viewport pill (not fullscreen) while
206
- a user-initiated backend restart is in flight. Slides down from the
207
- top, slides out on dismiss. Pinned center so it survives PWA WCO /
208
- standalone window-control overlays without bumping into them. */
205
+ /* RestartOverlay — a transient pill shown while a user-initiated backend
206
+ restart is in flight. Styled as a toast and pinned to the same
207
+ bottom-right slot so every floating notification in the app lives in
208
+ one place and reads the same. (It only ever coexists with a real toast
209
+ on restart *failure*, by which point the banner has already unmounted,
210
+ so sharing the slot doesn't collide.) Colors track the theme via
211
+ var(--ink)/var(--bg) — the old hardcoded #fff text went invisible on
212
+ the light pill dark mode produces. */
209
213
  .restart-banner {
210
214
  position: fixed;
211
- top: env(titlebar-area-height, 16px);
212
- left: 50%;
213
- transform: translateX(-50%);
215
+ bottom: var(--s-5);
216
+ right: var(--s-5);
214
217
  z-index: 1200;
215
- display: inline-flex;
218
+ display: flex;
216
219
  align-items: center;
217
- gap: 10px;
218
- padding: 8px 16px;
219
- border-radius: 999px;
220
+ gap: 12px;
221
+ max-width: 380px;
222
+ padding: 11px 16px 11px 14px;
220
223
  background: var(--ink);
221
- color: #fff;
224
+ color: var(--bg);
225
+ border-radius: 6px;
222
226
  font-size: 12.5px;
223
- font-weight: 500;
227
+ font-weight: 400;
224
228
  letter-spacing: -0.005em;
225
- box-shadow: 0 4px 18px rgba(0, 0, 0, 0.18);
226
- animation: restart-banner-in .18s ease-out;
229
+ line-height: 1.45;
230
+ box-shadow:
231
+ 0 10px 32px -8px rgba(26, 24, 21, 0.30),
232
+ 0 2px 6px rgba(26, 24, 21, 0.12),
233
+ inset 0 0 0 1px rgba(255, 255, 255, 0.06);
234
+ animation: toast-pop-in .28s cubic-bezier(.34, 1.4, .64, 1);
227
235
  }
228
- @keyframes restart-banner-in {
229
- from { opacity: 0; transform: translate(-50%, -8px); }
230
- to { opacity: 1; transform: translate(-50%, 0); }
236
+ @keyframes toast-pop-in {
237
+ from { opacity: 0; transform: translateY(10px) scale(0.98); }
238
+ to { opacity: 1; transform: translateY(0) scale(1); }
231
239
  }
232
240
  .restart-banner-spinner {
233
- width: 12px;
234
- height: 12px;
241
+ width: 13px;
242
+ height: 13px;
243
+ flex-shrink: 0;
235
244
  border-radius: 50%;
236
- border: 2px solid rgba(255, 255, 255, 0.25);
237
- border-top-color: #fff;
245
+ /* currentColor = the pill's text color (var(--bg)), so the spinner
246
+ stays legible in both themes without a hardcoded white. */
247
+ border: 2px solid color-mix(in srgb, currentColor 28%, transparent);
248
+ border-top-color: currentColor;
238
249
  animation: restart-spin 0.7s linear infinite;
239
250
  }
240
251
  .restart-banner-text { white-space: nowrap; }
@@ -312,8 +323,11 @@
312
323
  font-weight: 500;
313
324
  padding: 3px 6px;
314
325
  border-radius: 4px;
315
- background: rgba(255, 255, 255, 0.10);
316
- color: rgba(255, 255, 255, 0.75);
326
+ /* currentColor = the pill's text (var(--bg)), so the default chip stays
327
+ visible whether the pill is dark (light theme) or light (dark theme).
328
+ The .ok / .error variants below override with their own colored bg. */
329
+ background: color-mix(in srgb, currentColor 14%, transparent);
330
+ color: currentColor;
317
331
  flex-shrink: 0;
318
332
  line-height: 1;
319
333
  }
@@ -63,7 +63,11 @@
63
63
  .action.danger {
64
64
  background: var(--red);
65
65
  border-color: var(--red);
66
- color: var(--bg-elev);
66
+ /* Always light text — the danger red is dark in both themes, so it must
67
+ NOT follow --bg-elev (which is a dark surface in dark mode and would
68
+ render black text on red). In light mode --bg-elev was #fff anyway,
69
+ so this is identical there. */
70
+ color: #fff;
67
71
  }
68
72
  .action.danger:hover {
69
73
  background: #9a3636;
@@ -121,11 +125,15 @@ textarea {
121
125
  font-family: var(--body);
122
126
  font-size: 12.5px;
123
127
  font-weight: 500;
124
- padding: 5px 14px;
128
+ padding: 5px 13px;
125
129
  border-radius: 6px;
126
130
  cursor: pointer;
127
131
  transition: background .14s ease, color .14s ease;
132
+ display: inline-flex;
133
+ align-items: center;
134
+ gap: 6px;
128
135
  }
136
+ .seg-btn svg { width: 14px; height: 14px; opacity: 0.85; }
129
137
  .seg-btn:hover { color: var(--ink); }
130
138
  .seg-btn.is-active {
131
139
  background: var(--bg-elev);
@@ -1334,12 +1334,14 @@
1334
1334
  .adopt-tab.is-active {
1335
1335
  background: var(--ink);
1336
1336
  border-color: var(--ink);
1337
- color: #fff;
1337
+ /* var(--bg-elev), not #fff — the slab is var(--ink), which is LIGHT in
1338
+ dark mode, so white text would be invisible. --bg-elev tracks it. */
1339
+ color: var(--bg-elev);
1338
1340
  font-weight: 500;
1339
1341
  }
1340
1342
  .adopt-tab.is-active .adopt-tab-count {
1341
- background: rgba(255, 255, 255, 0.22);
1342
- color: #fff;
1343
+ background: color-mix(in srgb, var(--bg-elev) 22%, transparent);
1344
+ color: var(--bg-elev);
1343
1345
  }
1344
1346
  .adopt-tab-icon { display: inline-flex; width: 16px; height: 16px; }
1345
1347
  .adopt-tab-icon svg { width: 100%; height: 100%; }
@@ -2582,7 +2584,9 @@
2582
2584
  padding: 1px 7px;
2583
2585
  border-radius: 999px;
2584
2586
  background: var(--ink);
2585
- color: #fff;
2587
+ /* var(--bg-elev) tracks the --ink slab (light in dark mode) so the code
2588
+ stays legible; #fff would be white-on-light. */
2589
+ color: var(--bg-elev);
2586
2590
  font-variant-numeric: tabular-nums;
2587
2591
  }
2588
2592
  .remote-device-name { min-width: 0; }
@@ -113,6 +113,15 @@ export const IconMonitor = ic('0 0 24 24', html`
113
113
  <line x1="12" y1="16" x2="12" y2="20"/>
114
114
  `, 13);
115
115
 
116
+ // Light / dark theme glyphs for the Appearance toggle.
117
+ export const IconSun = ic('0 0 24 24', html`
118
+ <circle cx="12" cy="12" r="4"/>
119
+ <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
120
+ `, 14);
121
+ export const IconMoon = ic('0 0 24 24', html`
122
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
123
+ `, 14);
124
+
116
125
  // "> _" terminal prompt — for the Terminals nav tab
117
126
  export const IconTerminal = ic('0 0 24 24', html`
118
127
  <polyline points="4 17 10 11 4 5"/>
@@ -24,7 +24,7 @@ import { Card } from '../components/Card.js';
24
24
  import { PageTitleBar } from '../components/PageTitleBar.js';
25
25
  import { EntityFormModal } from '../components/EntityFormModal.js';
26
26
  import { useDragSort } from '../components/useDragSort.js';
27
- import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconRefresh, IconChevronUp, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor } from '../icons.js';
27
+ import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconRefresh, IconChevronUp, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor, IconSun, IconMoon, IconMonitor } from '../icons.js';
28
28
  import { parseArgs, formatArgs } from '../util.js';
29
29
 
30
30
  // Tokenize the three free-form args fields into string[] before they hit
@@ -584,9 +584,9 @@ function RestartButton() {
584
584
  function ThemeToggle() {
585
585
  const mode = themeMode.value;
586
586
  const opts = [
587
- { id: 'light', label: 'Light' },
588
- { id: 'dark', label: 'Dark' },
589
- { id: 'system', label: 'System' },
587
+ { id: 'light', label: 'Light', icon: IconSun },
588
+ { id: 'dark', label: 'Dark', icon: IconMoon },
589
+ { id: 'system', label: 'System', icon: IconMonitor },
590
590
  ];
591
591
  return html`
592
592
  <div class="seg" role="group" aria-label="Appearance">
@@ -594,7 +594,9 @@ function ThemeToggle() {
594
594
  <button key=${o.id} type="button"
595
595
  class=${`seg-btn${mode === o.id ? ' is-active' : ''}`}
596
596
  aria-pressed=${mode === o.id}
597
- onClick=${() => setThemeMode(o.id)}>${o.label}</button>`)}
597
+ onClick=${() => setThemeMode(o.id)}>
598
+ <${o.icon} /><span>${o.label}</span>
599
+ </button>`)}
598
600
  </div>`;
599
601
  }
600
602
 
@@ -311,14 +311,16 @@ export function RemotePage() {
311
311
  const [deviceList, setDeviceList] = useState([]);
312
312
  const pollRef = useRef(null);
313
313
 
314
- async function refresh() {
314
+ // Tunnel status and the device list are fetched INDEPENDENTLY, not as a
315
+ // bundled Promise.all. /api/tunnel/status can lag behind a cold provider
316
+ // probe; the device list is cheap. Coupling them made the (fast) device
317
+ // list wait on the (slow) status round-trip, so the whole page appeared
318
+ // to refresh in one delayed lump. Now each updates its own state the
319
+ // moment its own fetch lands.
320
+ async function refreshStatus() {
315
321
  try {
316
- const [s, devs] = await Promise.all([
317
- api('GET', '/api/tunnel/status'),
318
- api('GET', '/api/devices').catch(() => ({ devices: [] })),
319
- ]);
322
+ const s = await api('GET', '/api/tunnel/status');
320
323
  setStatus(s);
321
- setDeviceList(devs.devices || []);
322
324
  setTokenLocal((cur) => cur || s.token || '');
323
325
  setProvider((cur) => {
324
326
  if (s.running && s.provider) return s.provider;
@@ -336,6 +338,13 @@ export function RemotePage() {
336
338
  } catch {}
337
339
  } catch (e) { setToast(`status load failed · ${e.message}`, 'error'); }
338
340
  }
341
+ async function refreshDevices() {
342
+ try {
343
+ const devs = await api('GET', '/api/devices');
344
+ setDeviceList(devs.devices || []);
345
+ } catch { /* non-critical — keep the last good list on a transient error */ }
346
+ }
347
+ function refresh() { refreshStatus(); refreshDevices(); }
339
348
 
340
349
  useEffect(() => {
341
350
  refresh();
@@ -344,11 +353,11 @@ export function RemotePage() {
344
353
  }, []);
345
354
 
346
355
  async function onApproveDevice(id) {
347
- try { await api('POST', `/api/devices/${encodeURIComponent(id)}/approve`); refresh(); setToast('Device approved', 'ok'); }
356
+ try { await api('POST', `/api/devices/${encodeURIComponent(id)}/approve`); refreshDevices(); setToast('Device approved', 'ok'); }
348
357
  catch (e) { setToast(`approve failed · ${e.message}`, 'error'); }
349
358
  }
350
359
  async function onRejectDevice(id) {
351
- try { await api('POST', `/api/devices/${encodeURIComponent(id)}/reject`); refresh(); setToast('Device rejected', 'ok'); }
360
+ try { await api('POST', `/api/devices/${encodeURIComponent(id)}/reject`); refreshDevices(); setToast('Device rejected', 'ok'); }
352
361
  catch (e) { setToast(`reject failed · ${e.message}`, 'error'); }
353
362
  }
354
363
  async function onDeleteDevice(d) {
@@ -357,7 +366,7 @@ export function RemotePage() {
357
366
  { title: 'Delete device record', okLabel: 'Delete', danger: true },
358
367
  );
359
368
  if (!ok) return;
360
- try { await api('DELETE', `/api/devices/${encodeURIComponent(d.id)}`); refresh(); setToast('Device deleted', 'ok'); }
369
+ try { await api('DELETE', `/api/devices/${encodeURIComponent(d.id)}`); refreshDevices(); setToast('Device deleted', 'ok'); }
361
370
  catch (e) { setToast(`delete failed · ${e.message}`, 'error'); }
362
371
  }
363
372
  async function onRevokeDevice(d) {
@@ -365,13 +374,13 @@ export function RemotePage() {
365
374
  title: 'Revoke device', okLabel: 'Revoke', danger: true,
366
375
  });
367
376
  if (!ok) return;
368
- try { await api('POST', `/api/devices/${encodeURIComponent(d.id)}/revoke`); refresh(); setToast('Access revoked', 'ok'); }
377
+ try { await api('POST', `/api/devices/${encodeURIComponent(d.id)}/revoke`); refreshDevices(); setToast('Access revoked', 'ok'); }
369
378
  catch (e) { setToast(`revoke failed · ${e.message}`, 'error'); }
370
379
  }
371
380
  async function onRenameDevice(d) {
372
381
  const next = await ccsmPrompt('Rename device', d.label || '', { okLabel: 'Save' });
373
382
  if (next === null) return;
374
- try { await api('PUT', `/api/devices/${encodeURIComponent(d.id)}`, { label: next.trim() }); refresh(); }
383
+ try { await api('PUT', `/api/devices/${encodeURIComponent(d.id)}`, { label: next.trim() }); refreshDevices(); }
375
384
  catch (e) { setToast(`rename failed · ${e.message}`, 'error'); }
376
385
  }
377
386