@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 +29 -3
- package/package.json +1 -1
- package/public/css/dark.css +4 -0
- package/public/css/feedback.css +38 -24
- package/public/css/forms.css +10 -2
- package/public/css/widgets.css +8 -4
- package/public/js/icons.js +9 -0
- package/public/js/pages/ConfigurePage.js +7 -5
- package/public/js/pages/RemotePage.js +20 -11
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.)
|
|
293
|
-
|
|
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:
|
|
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.
|
|
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",
|
package/public/css/dark.css
CHANGED
|
@@ -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); }
|
package/public/css/feedback.css
CHANGED
|
@@ -202,39 +202,50 @@
|
|
|
202
202
|
text-align: center;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
/* RestartOverlay —
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
transform: translateX(-50%);
|
|
215
|
+
bottom: var(--s-5);
|
|
216
|
+
right: var(--s-5);
|
|
214
217
|
z-index: 1200;
|
|
215
|
-
display:
|
|
218
|
+
display: flex;
|
|
216
219
|
align-items: center;
|
|
217
|
-
gap:
|
|
218
|
-
|
|
219
|
-
|
|
220
|
+
gap: 12px;
|
|
221
|
+
max-width: 380px;
|
|
222
|
+
padding: 11px 16px 11px 14px;
|
|
220
223
|
background: var(--ink);
|
|
221
|
-
color:
|
|
224
|
+
color: var(--bg);
|
|
225
|
+
border-radius: 6px;
|
|
222
226
|
font-size: 12.5px;
|
|
223
|
-
font-weight:
|
|
227
|
+
font-weight: 400;
|
|
224
228
|
letter-spacing: -0.005em;
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
229
|
-
from { opacity: 0; transform:
|
|
230
|
-
to { opacity: 1; transform:
|
|
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:
|
|
234
|
-
height:
|
|
241
|
+
width: 13px;
|
|
242
|
+
height: 13px;
|
|
243
|
+
flex-shrink: 0;
|
|
235
244
|
border-radius: 50%;
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
}
|
package/public/css/forms.css
CHANGED
|
@@ -63,7 +63,11 @@
|
|
|
63
63
|
.action.danger {
|
|
64
64
|
background: var(--red);
|
|
65
65
|
border-color: var(--red);
|
|
66
|
-
|
|
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
|
|
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);
|
package/public/css/widgets.css
CHANGED
|
@@ -1334,12 +1334,14 @@
|
|
|
1334
1334
|
.adopt-tab.is-active {
|
|
1335
1335
|
background: var(--ink);
|
|
1336
1336
|
border-color: var(--ink);
|
|
1337
|
-
|
|
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:
|
|
1342
|
-
color:
|
|
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
|
-
|
|
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; }
|
package/public/js/icons.js
CHANGED
|
@@ -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)}
|
|
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
|
-
|
|
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
|
|
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`);
|
|
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`);
|
|
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)}`);
|
|
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`);
|
|
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() });
|
|
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
|
|