@bakapiano/ccsm 0.16.0 → 0.17.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/bin/ccsm.js +33 -0
- package/lib/config.js +20 -1
- package/lib/folders.js +23 -4
- package/package.json +1 -1
- package/public/css/feedback.css +177 -0
- package/public/css/sidebar.css +17 -0
- package/public/js/api.js +15 -2
- package/public/js/components/App.js +2 -0
- package/public/js/components/HealthOverlay.js +81 -0
- package/public/js/components/KeybindingRecorder.js +138 -0
- package/public/js/components/Sidebar.js +87 -15
- package/public/js/keybindings.js +36 -6
- package/public/js/pages/AboutPage.js +33 -9
- package/public/js/pages/ConfigurePage.js +15 -36
- package/public/js/state.js +22 -3
- package/scripts/upgrade-helper.js +551 -62
- package/server.js +43 -4
package/bin/ccsm.js
CHANGED
|
@@ -39,6 +39,15 @@ function loadPreferredPort() {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Cheap "is this pid still alive" check using kill(pid, 0). Returns
|
|
43
|
+
// true for live pids we own, also true for pids in other security
|
|
44
|
+
// contexts (EPERM means it exists, we just can't signal it).
|
|
45
|
+
function pidAlive(pid) {
|
|
46
|
+
if (!pid) return false;
|
|
47
|
+
try { process.kill(pid, 0); return true; }
|
|
48
|
+
catch (e) { return e.code === 'EPERM'; }
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
function probe(port, timeoutMs = 800) {
|
|
43
52
|
return new Promise((resolve) => {
|
|
44
53
|
const req = http.get(`http://localhost:${port}/api/health`, { timeout: timeoutMs }, (res) => {
|
|
@@ -106,6 +115,30 @@ function isSameVersion(running) {
|
|
|
106
115
|
const SILENT = !!protocol; // ccsm:// invocations should not open a new browser
|
|
107
116
|
const port = loadPreferredPort();
|
|
108
117
|
|
|
118
|
+
// Upgrade-in-progress guard. The updater helper writes
|
|
119
|
+
// ~/.ccsm/.upgrade.lock at start. If a ccsm:// click (or any other
|
|
120
|
+
// launcher trigger) races during an in-flight install, spawning a
|
|
121
|
+
// new server would: (a) fight npm for the package dir, EBUSY; or
|
|
122
|
+
// (b) bind port 7777 before the helper's own respawn does. Either
|
|
123
|
+
// way the upgrade derails. Bail out instead — the helper's UI on
|
|
124
|
+
// 7779 is already showing the user what's happening.
|
|
125
|
+
const lockPath = path.join(HOME, '.upgrade.lock');
|
|
126
|
+
try {
|
|
127
|
+
const raw = fs.readFileSync(lockPath, 'utf8');
|
|
128
|
+
const lock = JSON.parse(raw);
|
|
129
|
+
const ageMs = Date.now() - (lock.startedAt || 0);
|
|
130
|
+
const ownerAlive = lock.pid ? pidAlive(lock.pid) : false;
|
|
131
|
+
if (ownerAlive && ageMs < 10 * 60_000) {
|
|
132
|
+
console.log(`ccsm: upgrade in progress (helper pid=${lock.pid}, ${Math.round(ageMs/1000)}s ago, target=${lock.target || '?'})`);
|
|
133
|
+
console.log(` see http://localhost:${lock.helperPort || 7779}/ for live progress`);
|
|
134
|
+
process.exit(0);
|
|
135
|
+
}
|
|
136
|
+
// Stale lock (pid dead OR > 10min) — clean up and continue.
|
|
137
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
138
|
+
} catch {
|
|
139
|
+
// ENOENT or parse error → no lock, proceed.
|
|
140
|
+
}
|
|
141
|
+
|
|
109
142
|
// Case 1: existing instance on the preferred port
|
|
110
143
|
let existing = await probe(port);
|
|
111
144
|
|
package/lib/config.js
CHANGED
|
@@ -152,7 +152,7 @@ function mergeWithDefaults(partial) {
|
|
|
152
152
|
// Normalize per-CLI fields.
|
|
153
153
|
out.clis = out.clis.map((c) => {
|
|
154
154
|
const { installed, installPath, resumeArgs, ...rest } = c; // strip computed probe fields + v0.x resumeArgs
|
|
155
|
-
|
|
155
|
+
const normalized = {
|
|
156
156
|
...rest,
|
|
157
157
|
args: Array.isArray(rest.args) ? rest.args : [],
|
|
158
158
|
resumeIdArgs: Array.isArray(rest.resumeIdArgs) ? rest.resumeIdArgs : [],
|
|
@@ -161,6 +161,25 @@ function mergeWithDefaults(partial) {
|
|
|
161
161
|
type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
|
|
162
162
|
builtin: !!rest.builtin,
|
|
163
163
|
};
|
|
164
|
+
// Type-based fallback for non-builtin CLIs (wrappers like `ccp`
|
|
165
|
+
// that just call claude under the hood). If user picked
|
|
166
|
+
// type='claude' but left newSessionIdArgs / resumeIdArgs blank,
|
|
167
|
+
// assume they want the same args claude / copilot / codex use
|
|
168
|
+
// canonically — without this the wrapped CLI gets spawned with
|
|
169
|
+
// no UUID and ccsm can never recapture the upstream session.
|
|
170
|
+
// Builtins are already handled by the loop above with `def`.
|
|
171
|
+
if (!normalized.builtin && normalized.type !== 'other') {
|
|
172
|
+
const template = DEFAULT_CLIS.find((d) => d.type === normalized.type);
|
|
173
|
+
if (template) {
|
|
174
|
+
if (normalized.newSessionIdArgs.length === 0) {
|
|
175
|
+
normalized.newSessionIdArgs = [...template.newSessionIdArgs];
|
|
176
|
+
}
|
|
177
|
+
if (normalized.resumeIdArgs.length === 0) {
|
|
178
|
+
normalized.resumeIdArgs = [...template.resumeIdArgs];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return normalized;
|
|
164
183
|
});
|
|
165
184
|
// Make sure defaultCliId points at an actual CLI; fall back to first.
|
|
166
185
|
if (!out.clis.find((c) => c.id === out.defaultCliId)) {
|
package/lib/folders.js
CHANGED
|
@@ -16,15 +16,30 @@ const { atomicWriteJson, withFileLock } = require('./atomicJson');
|
|
|
16
16
|
|
|
17
17
|
const FILE = path.join(DATA_DIR, 'folders.json');
|
|
18
18
|
|
|
19
|
+
// Sentinel for the synthetic "Unsorted" folder. Sessions with
|
|
20
|
+
// folderId === null render under it. We always materialize it in the
|
|
21
|
+
// returned list so the sidebar can drag-reorder it like a real folder,
|
|
22
|
+
// but create/update/delete refuse to touch it.
|
|
23
|
+
const UNSORTED_ID = 'unsorted';
|
|
24
|
+
function unsortedDefault(order) {
|
|
25
|
+
return { id: UNSORTED_ID, name: 'Unsorted', order, builtin: true };
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
async function loadAll() {
|
|
29
|
+
let list = [];
|
|
20
30
|
try {
|
|
21
31
|
const raw = await fs.readFile(FILE, 'utf8');
|
|
22
32
|
const j = JSON.parse(raw);
|
|
23
|
-
|
|
33
|
+
if (Array.isArray(j)) list = j;
|
|
24
34
|
} catch (e) {
|
|
25
|
-
if (e.code
|
|
26
|
-
throw e;
|
|
35
|
+
if (e.code !== 'ENOENT') throw e;
|
|
27
36
|
}
|
|
37
|
+
// Ensure the synthetic Unsorted entry is present. New install: append
|
|
38
|
+
// at the end. Existing install pre-Unsorted-draggable: same.
|
|
39
|
+
if (!list.find((f) => f.id === UNSORTED_ID)) {
|
|
40
|
+
list = list.concat(unsortedDefault(list.length));
|
|
41
|
+
}
|
|
42
|
+
return list;
|
|
28
43
|
}
|
|
29
44
|
|
|
30
45
|
async function saveAll(list) {
|
|
@@ -52,13 +67,16 @@ async function create({ name }) {
|
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
async function update(id, patch) {
|
|
70
|
+
if (id === UNSORTED_ID && typeof patch.name === 'string') {
|
|
71
|
+
throw new Error('cannot rename the Unsorted bucket');
|
|
72
|
+
}
|
|
55
73
|
return withFileLock(FILE, async () => {
|
|
56
74
|
const list = await loadAll();
|
|
57
75
|
const idx = list.findIndex((f) => f.id === id);
|
|
58
76
|
if (idx < 0) return null;
|
|
59
77
|
// Allow rename + reorder, ignore other keys.
|
|
60
78
|
const allowed = {};
|
|
61
|
-
if (typeof patch.name === 'string') allowed.name = patch.name.trim();
|
|
79
|
+
if (id !== UNSORTED_ID && typeof patch.name === 'string') allowed.name = patch.name.trim();
|
|
62
80
|
if (typeof patch.order === 'number') allowed.order = patch.order;
|
|
63
81
|
list[idx] = { ...list[idx], ...allowed };
|
|
64
82
|
await saveAll(list);
|
|
@@ -67,6 +85,7 @@ async function update(id, patch) {
|
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
async function remove(id) {
|
|
88
|
+
if (id === UNSORTED_ID) throw new Error('cannot delete the Unsorted bucket');
|
|
70
89
|
return withFileLock(FILE, async () => {
|
|
71
90
|
const list = await loadAll();
|
|
72
91
|
const idx = list.findIndex((f) => f.id === id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
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/feedback.css
CHANGED
|
@@ -301,3 +301,180 @@
|
|
|
301
301
|
0%, 100% { box-shadow: 0 0 0 0 rgba(26, 24, 21, 0.25); }
|
|
302
302
|
50% { box-shadow: 0 0 0 4px rgba(26, 24, 21, 0); }
|
|
303
303
|
}
|
|
304
|
+
|
|
305
|
+
/* ── Health overlay · full-screen modal while backend is down ─────── */
|
|
306
|
+
.health-overlay {
|
|
307
|
+
position: fixed;
|
|
308
|
+
inset: 0;
|
|
309
|
+
z-index: 9999;
|
|
310
|
+
background: rgba(26, 24, 21, 0.48);
|
|
311
|
+
backdrop-filter: blur(4px);
|
|
312
|
+
-webkit-backdrop-filter: blur(4px);
|
|
313
|
+
display: flex;
|
|
314
|
+
align-items: center;
|
|
315
|
+
justify-content: center;
|
|
316
|
+
animation: health-fade-in 0.2s ease-out;
|
|
317
|
+
}
|
|
318
|
+
@keyframes health-fade-in {
|
|
319
|
+
from { opacity: 0; }
|
|
320
|
+
to { opacity: 1; }
|
|
321
|
+
}
|
|
322
|
+
.health-card {
|
|
323
|
+
background: var(--bg-elev);
|
|
324
|
+
border: 1px solid var(--border);
|
|
325
|
+
border-radius: 14px;
|
|
326
|
+
padding: 36px 44px;
|
|
327
|
+
max-width: 420px;
|
|
328
|
+
text-align: center;
|
|
329
|
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
|
330
|
+
font-family: var(--body);
|
|
331
|
+
}
|
|
332
|
+
.health-spinner {
|
|
333
|
+
width: 22px;
|
|
334
|
+
height: 22px;
|
|
335
|
+
border: 2px solid var(--border);
|
|
336
|
+
border-top-color: var(--accent, #4a73a5);
|
|
337
|
+
border-radius: 50%;
|
|
338
|
+
margin: 0 auto 14px;
|
|
339
|
+
animation: health-spin 0.8s linear infinite;
|
|
340
|
+
}
|
|
341
|
+
@keyframes health-spin {
|
|
342
|
+
to { transform: rotate(360deg); }
|
|
343
|
+
}
|
|
344
|
+
.health-dot {
|
|
345
|
+
width: 12px;
|
|
346
|
+
height: 12px;
|
|
347
|
+
border-radius: 50%;
|
|
348
|
+
background: var(--red, #b73f3f);
|
|
349
|
+
margin: 0 auto 14px;
|
|
350
|
+
}
|
|
351
|
+
.health-title {
|
|
352
|
+
font-size: 15px;
|
|
353
|
+
font-weight: 500;
|
|
354
|
+
letter-spacing: -0.005em;
|
|
355
|
+
color: var(--ink);
|
|
356
|
+
margin-bottom: 6px;
|
|
357
|
+
}
|
|
358
|
+
.health-meta {
|
|
359
|
+
font-size: 12.5px;
|
|
360
|
+
color: var(--ink-mid);
|
|
361
|
+
line-height: 1.4;
|
|
362
|
+
margin-bottom: 14px;
|
|
363
|
+
}
|
|
364
|
+
.health-start {
|
|
365
|
+
display: inline-block;
|
|
366
|
+
margin-top: 4px;
|
|
367
|
+
text-decoration: none;
|
|
368
|
+
}
|
|
369
|
+
.health-hint {
|
|
370
|
+
font-size: 11.5px;
|
|
371
|
+
color: var(--ink-muted);
|
|
372
|
+
margin-top: 14px;
|
|
373
|
+
}
|
|
374
|
+
.health-hint code {
|
|
375
|
+
font-family: var(--mono);
|
|
376
|
+
background: var(--bg);
|
|
377
|
+
padding: 1px 5px;
|
|
378
|
+
border-radius: 3px;
|
|
379
|
+
border: 1px solid var(--border);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* ── Keybinding recorder modal ───────────────────────────────────── */
|
|
383
|
+
.kbd-recorder-overlay {
|
|
384
|
+
position: fixed;
|
|
385
|
+
inset: 0;
|
|
386
|
+
z-index: 9999;
|
|
387
|
+
background: rgba(26, 24, 21, 0.5);
|
|
388
|
+
backdrop-filter: blur(4px);
|
|
389
|
+
-webkit-backdrop-filter: blur(4px);
|
|
390
|
+
display: flex;
|
|
391
|
+
align-items: center;
|
|
392
|
+
justify-content: center;
|
|
393
|
+
animation: kbd-rec-fade-in 0.16s ease-out;
|
|
394
|
+
}
|
|
395
|
+
@keyframes kbd-rec-fade-in {
|
|
396
|
+
from { opacity: 0; }
|
|
397
|
+
to { opacity: 1; }
|
|
398
|
+
}
|
|
399
|
+
.kbd-recorder-card {
|
|
400
|
+
background: var(--bg-elev);
|
|
401
|
+
border: 1px solid var(--border);
|
|
402
|
+
border-radius: 14px;
|
|
403
|
+
padding: 32px 40px;
|
|
404
|
+
min-width: 380px;
|
|
405
|
+
max-width: 520px;
|
|
406
|
+
text-align: center;
|
|
407
|
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
|
408
|
+
font-family: var(--body);
|
|
409
|
+
}
|
|
410
|
+
.kbd-recorder-label {
|
|
411
|
+
font-size: 12.5px;
|
|
412
|
+
color: var(--ink-mid);
|
|
413
|
+
letter-spacing: 0.02em;
|
|
414
|
+
text-transform: uppercase;
|
|
415
|
+
margin-bottom: 6px;
|
|
416
|
+
}
|
|
417
|
+
.kbd-recorder-action {
|
|
418
|
+
font-size: 18px;
|
|
419
|
+
font-weight: 500;
|
|
420
|
+
letter-spacing: -0.01em;
|
|
421
|
+
color: var(--ink);
|
|
422
|
+
margin-bottom: 24px;
|
|
423
|
+
}
|
|
424
|
+
.kbd-recorder-keys {
|
|
425
|
+
display: flex;
|
|
426
|
+
align-items: center;
|
|
427
|
+
justify-content: center;
|
|
428
|
+
gap: 8px;
|
|
429
|
+
min-height: 48px;
|
|
430
|
+
margin-bottom: 20px;
|
|
431
|
+
flex-wrap: wrap;
|
|
432
|
+
}
|
|
433
|
+
.kbd-recorder-key {
|
|
434
|
+
display: inline-flex;
|
|
435
|
+
align-items: center;
|
|
436
|
+
justify-content: center;
|
|
437
|
+
min-width: 36px;
|
|
438
|
+
height: 36px;
|
|
439
|
+
padding: 0 10px;
|
|
440
|
+
background: var(--bg);
|
|
441
|
+
border: 1px solid var(--border);
|
|
442
|
+
border-bottom-width: 2px;
|
|
443
|
+
border-radius: 6px;
|
|
444
|
+
font-family: var(--mono);
|
|
445
|
+
font-size: 13px;
|
|
446
|
+
font-weight: 500;
|
|
447
|
+
color: var(--ink);
|
|
448
|
+
animation: kbd-rec-pop 0.12s ease-out;
|
|
449
|
+
}
|
|
450
|
+
@keyframes kbd-rec-pop {
|
|
451
|
+
from { transform: scale(0.85); opacity: 0.4; }
|
|
452
|
+
to { transform: scale(1); opacity: 1; }
|
|
453
|
+
}
|
|
454
|
+
.kbd-recorder-plus {
|
|
455
|
+
color: var(--ink-muted);
|
|
456
|
+
font-size: 14px;
|
|
457
|
+
font-weight: 300;
|
|
458
|
+
}
|
|
459
|
+
.kbd-recorder-placeholder {
|
|
460
|
+
color: var(--ink-muted);
|
|
461
|
+
font-size: 13px;
|
|
462
|
+
}
|
|
463
|
+
.kbd-recorder-actions {
|
|
464
|
+
display: flex;
|
|
465
|
+
justify-content: center;
|
|
466
|
+
gap: 8px;
|
|
467
|
+
margin-bottom: 14px;
|
|
468
|
+
}
|
|
469
|
+
.kbd-recorder-hint {
|
|
470
|
+
font-size: 11.5px;
|
|
471
|
+
color: var(--ink-muted);
|
|
472
|
+
}
|
|
473
|
+
.kbd-recorder-hint kbd {
|
|
474
|
+
font-family: var(--mono);
|
|
475
|
+
font-size: 11px;
|
|
476
|
+
background: var(--bg);
|
|
477
|
+
border: 1px solid var(--border);
|
|
478
|
+
border-radius: 3px;
|
|
479
|
+
padding: 1px 5px;
|
|
480
|
+
}
|
package/public/css/sidebar.css
CHANGED
|
@@ -545,6 +545,23 @@ body.is-resizing-sidebar * {
|
|
|
545
545
|
background: var(--sidebar-active);
|
|
546
546
|
font-weight: 500;
|
|
547
547
|
}
|
|
548
|
+
/* Drop-line shown above the row when a sibling session is being
|
|
549
|
+
dragged over it for within-folder reorder. Top-only inset shadow
|
|
550
|
+
reads as a 2px insertion mark without shifting layout. */
|
|
551
|
+
.tree-session.is-reorder-target {
|
|
552
|
+
position: relative;
|
|
553
|
+
}
|
|
554
|
+
.tree-session.is-reorder-target::before {
|
|
555
|
+
content: "";
|
|
556
|
+
position: absolute;
|
|
557
|
+
top: -1px;
|
|
558
|
+
left: 4px;
|
|
559
|
+
right: 4px;
|
|
560
|
+
height: 2px;
|
|
561
|
+
background: var(--ink-mid);
|
|
562
|
+
border-radius: 1px;
|
|
563
|
+
pointer-events: none;
|
|
564
|
+
}
|
|
548
565
|
/* Status dot · deliberately understated. The earlier version had a
|
|
549
566
|
green dot + soft glow + expanding halo pulse; in a sidebar with
|
|
550
567
|
eight running sessions it read as a row of strobing alerts. Now:
|
package/public/js/api.js
CHANGED
|
@@ -187,6 +187,11 @@ export async function setSessionFolder(sessionId, folderId) {
|
|
|
187
187
|
await loadSessions();
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
export async function reorderSessions(folderId, ids) {
|
|
191
|
+
await api('POST', '/api/sessions/reorder', { folderId: folderId || null, ids });
|
|
192
|
+
await loadSessions();
|
|
193
|
+
}
|
|
194
|
+
|
|
190
195
|
export async function setSessionTitle(sessionId, title) {
|
|
191
196
|
await api('PUT', `/api/sessions/${sessionId}`, { title });
|
|
192
197
|
await loadSessions();
|
|
@@ -280,6 +285,7 @@ export async function restartBackend() {
|
|
|
280
285
|
return api('POST', '/api/restart');
|
|
281
286
|
}
|
|
282
287
|
|
|
288
|
+
let consecutiveOffline = 0;
|
|
283
289
|
export async function pollHealth() {
|
|
284
290
|
const ctrl = new AbortController();
|
|
285
291
|
const t = setTimeout(() => ctrl.abort(), 3000);
|
|
@@ -287,9 +293,16 @@ export async function pollHealth() {
|
|
|
287
293
|
const r = await fetch(httpBase() + '/api/health', { signal: ctrl.signal });
|
|
288
294
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
289
295
|
const j = await r.json();
|
|
290
|
-
|
|
296
|
+
consecutiveOffline = 0;
|
|
297
|
+
S.serverHealth.value = { state: 'online', version: j.version, pid: j.pid, failureCount: 0 };
|
|
298
|
+
if (!S.hasBootedOnline.value) S.hasBootedOnline.value = true;
|
|
291
299
|
} catch (e) {
|
|
292
|
-
|
|
300
|
+
consecutiveOffline++;
|
|
301
|
+
S.serverHealth.value = {
|
|
302
|
+
state: 'offline',
|
|
303
|
+
error: String(e.message || e),
|
|
304
|
+
failureCount: consecutiveOffline,
|
|
305
|
+
};
|
|
293
306
|
} finally {
|
|
294
307
|
clearTimeout(t);
|
|
295
308
|
}
|
|
@@ -4,6 +4,7 @@ import { Sidebar } from './Sidebar.js';
|
|
|
4
4
|
import { Toast } from './Toast.js';
|
|
5
5
|
import { DialogHost } from './DialogHost.js';
|
|
6
6
|
import { OfflineBanner } from './OfflineBanner.js';
|
|
7
|
+
import { HealthOverlay } from './HealthOverlay.js';
|
|
7
8
|
import { SessionsPage } from '../pages/SessionsPage.js';
|
|
8
9
|
import { LaunchPage } from '../pages/LaunchPage.js';
|
|
9
10
|
import { ConfigurePage } from '../pages/ConfigurePage.js';
|
|
@@ -31,5 +32,6 @@ export function App() {
|
|
|
31
32
|
<${OfflineBanner} />
|
|
32
33
|
<${Toast} />
|
|
33
34
|
<${DialogHost} />
|
|
35
|
+
<${HealthOverlay} />
|
|
34
36
|
</div>`;
|
|
35
37
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Full-screen modal shown while the backend is unreachable.
|
|
2
|
+
//
|
|
3
|
+
// Two phases:
|
|
4
|
+
// - Early (failureCount < THRESHOLD): "Checking backend health…" with
|
|
5
|
+
// a spinner. Most outages resolve in one or two ticks — no need to
|
|
6
|
+
// scare the user.
|
|
7
|
+
// - Persistent (failureCount >= THRESHOLD): "Backend not running.
|
|
8
|
+
// Start backend" with a button that fires the ccsm:// protocol so
|
|
9
|
+
// Windows' registered launcher.vbs spawns ccsm.
|
|
10
|
+
//
|
|
11
|
+
// We never auto-resume. The user has to click the button — protects
|
|
12
|
+
// against repeated wake attempts during an in-flight upgrade or a
|
|
13
|
+
// crash loop.
|
|
14
|
+
//
|
|
15
|
+
// While offline we drive a faster (1.5s) poll loop directly so the
|
|
16
|
+
// modal dismisses promptly when the backend comes back, without
|
|
17
|
+
// waiting for the main 5s refresh interval.
|
|
18
|
+
|
|
19
|
+
import { html } from '../html.js';
|
|
20
|
+
import { useEffect } from 'preact/hooks';
|
|
21
|
+
import { serverHealth, hasBootedOnline } from '../state.js';
|
|
22
|
+
import { pollHealth, refreshAll } from '../api.js';
|
|
23
|
+
|
|
24
|
+
const THRESHOLD = 3; // failures before we switch from "checking" to "not running"
|
|
25
|
+
const FAST_POLL_MS = 1500;
|
|
26
|
+
|
|
27
|
+
export function HealthOverlay() {
|
|
28
|
+
const h = serverHealth.value;
|
|
29
|
+
const offline = h.state === 'offline';
|
|
30
|
+
const count = h.failureCount || 0;
|
|
31
|
+
|
|
32
|
+
// Don't render the overlay during the very first connect attempt
|
|
33
|
+
// (before we've ever been online) — main.js shows nothing prominent
|
|
34
|
+
// there anyway, and the modal flashing on every page load is
|
|
35
|
+
// annoying. Only show after we've seen the backend at least once.
|
|
36
|
+
const everSeen = hasBootedOnline.value;
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!offline) return;
|
|
40
|
+
const id = setInterval(() => { pollHealth(); }, FAST_POLL_MS);
|
|
41
|
+
return () => clearInterval(id);
|
|
42
|
+
}, [offline]);
|
|
43
|
+
|
|
44
|
+
// When the backend comes back online after we've shown the overlay,
|
|
45
|
+
// refresh all derived state once — sessions/folders/workspaces may
|
|
46
|
+
// have changed during the outage (post-restart, post-upgrade).
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!offline && everSeen) {
|
|
49
|
+
refreshAll().catch(() => {});
|
|
50
|
+
}
|
|
51
|
+
}, [offline]);
|
|
52
|
+
|
|
53
|
+
if (!offline || !everSeen) return null;
|
|
54
|
+
|
|
55
|
+
const showStart = count >= THRESHOLD;
|
|
56
|
+
|
|
57
|
+
return html`
|
|
58
|
+
<div class="health-overlay" role="dialog" aria-modal="true" aria-live="polite">
|
|
59
|
+
<div class="health-card">
|
|
60
|
+
${!showStart ? html`
|
|
61
|
+
<div class="health-spinner" aria-hidden="true"></div>
|
|
62
|
+
<div class="health-title">Checking backend health…</div>
|
|
63
|
+
<div class="health-meta">
|
|
64
|
+
${count === 0 ? 'Connecting…' : `${count} attempt${count > 1 ? 's' : ''}`}
|
|
65
|
+
</div>
|
|
66
|
+
` : html`
|
|
67
|
+
<div class="health-dot" aria-hidden="true"></div>
|
|
68
|
+
<div class="health-title">Backend not running</div>
|
|
69
|
+
<div class="health-meta">
|
|
70
|
+
${count} failed pings. Wake the backend manually below — we won't auto-restart.
|
|
71
|
+
</div>
|
|
72
|
+
<a class="action primary health-start" href="ccsm://start">
|
|
73
|
+
Start backend
|
|
74
|
+
</a>
|
|
75
|
+
<div class="health-hint">
|
|
76
|
+
Or run <code>ccsm</code> in a terminal.
|
|
77
|
+
</div>
|
|
78
|
+
`}
|
|
79
|
+
</div>
|
|
80
|
+
</div>`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Full-screen modal that captures a key combo with a live preview of
|
|
2
|
+
// what the user is currently holding. Click to record → push modifiers
|
|
3
|
+
// in real time → release / press a non-modifier key to commit → close.
|
|
4
|
+
// Esc with no modifiers cancels.
|
|
5
|
+
|
|
6
|
+
import { html } from '../html.js';
|
|
7
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
8
|
+
|
|
9
|
+
const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta']);
|
|
10
|
+
|
|
11
|
+
function modsFromEvent(ev) {
|
|
12
|
+
const out = [];
|
|
13
|
+
if (ev.ctrlKey) out.push('Ctrl');
|
|
14
|
+
if (ev.altKey) out.push('Alt');
|
|
15
|
+
if (ev.shiftKey) out.push('Shift');
|
|
16
|
+
if (ev.metaKey) out.push('Meta');
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function prettyKey(k) {
|
|
21
|
+
if (k === 'ArrowUp') return '↑';
|
|
22
|
+
if (k === 'ArrowDown') return '↓';
|
|
23
|
+
if (k === 'ArrowLeft') return '←';
|
|
24
|
+
if (k === 'ArrowRight') return '→';
|
|
25
|
+
if (k === ' ') return 'Space';
|
|
26
|
+
if (k === 'Escape') return 'Esc';
|
|
27
|
+
if (/^[a-z]$/.test(k)) return k.toUpperCase();
|
|
28
|
+
return k;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function KeybindingRecorder({ actionLabel, onCommit, onCancel }) {
|
|
32
|
+
// While `captured` is null, we're listening — display reflects whatever
|
|
33
|
+
// is currently held. Once a non-modifier key lands, we freeze that
|
|
34
|
+
// combo into `captured` and surface explicit Confirm / Try again
|
|
35
|
+
// buttons. Pressing another non-modifier key replaces the captured
|
|
36
|
+
// combo (useful when the user mis-pressed and wants to retry without
|
|
37
|
+
// clicking).
|
|
38
|
+
const [mods, setMods] = useState([]);
|
|
39
|
+
const [captured, setCaptured] = useState(null);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const onDown = (ev) => {
|
|
43
|
+
ev.preventDefault();
|
|
44
|
+
ev.stopPropagation();
|
|
45
|
+
|
|
46
|
+
// Esc with no modifiers = cancel. Esc-as-shortcut is allowed when
|
|
47
|
+
// any modifier is also held.
|
|
48
|
+
if (ev.key === 'Escape' && !ev.ctrlKey && !ev.altKey && !ev.shiftKey && !ev.metaKey) {
|
|
49
|
+
onCancel();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Enter while a combo is captured = confirm (so the user can finish
|
|
54
|
+
// with a single hand on the keyboard, no mouse round-trip).
|
|
55
|
+
if (ev.key === 'Enter' && captured && !ev.ctrlKey && !ev.altKey && !ev.shiftKey && !ev.metaKey) {
|
|
56
|
+
onCommit(captured);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const currentMods = modsFromEvent(ev);
|
|
61
|
+
if (MODIFIER_KEYS.has(ev.key)) {
|
|
62
|
+
setMods(currentMods);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Real key landed — freeze it; user still has to confirm.
|
|
67
|
+
let k = ev.key;
|
|
68
|
+
if (/^[a-z]$/.test(k)) k = k.toUpperCase();
|
|
69
|
+
const combo = [...currentMods, k].join('+');
|
|
70
|
+
setCaptured(combo);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const onUp = (ev) => {
|
|
74
|
+
setMods(modsFromEvent(ev));
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
window.addEventListener('keydown', onDown, true);
|
|
78
|
+
window.addEventListener('keyup', onUp, true);
|
|
79
|
+
return () => {
|
|
80
|
+
window.removeEventListener('keydown', onDown, true);
|
|
81
|
+
window.removeEventListener('keyup', onUp, true);
|
|
82
|
+
};
|
|
83
|
+
}, [onCommit, onCancel, captured]);
|
|
84
|
+
|
|
85
|
+
// The keys shown in the keycap row. While listening: live modifier
|
|
86
|
+
// state + last-pressed key (if captured replaced live state). When
|
|
87
|
+
// captured: parse the frozen combo back into parts so we show the
|
|
88
|
+
// exact thing about to be saved.
|
|
89
|
+
let parts;
|
|
90
|
+
if (captured) {
|
|
91
|
+
parts = captured.split('+');
|
|
92
|
+
} else {
|
|
93
|
+
parts = [...mods];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return html`
|
|
97
|
+
<div class="kbd-recorder-overlay" role="dialog" aria-modal="true"
|
|
98
|
+
onClick=${onCancel}>
|
|
99
|
+
<div class="kbd-recorder-card" onClick=${(ev) => ev.stopPropagation()}>
|
|
100
|
+
<div class="kbd-recorder-label">
|
|
101
|
+
${captured ? 'Captured shortcut for' : 'Press a shortcut for'}
|
|
102
|
+
</div>
|
|
103
|
+
<div class="kbd-recorder-action">${actionLabel}</div>
|
|
104
|
+
|
|
105
|
+
<div class="kbd-recorder-keys">
|
|
106
|
+
${parts.length === 0
|
|
107
|
+
? html`<span class="kbd-recorder-placeholder">Press any key combo…</span>`
|
|
108
|
+
: parts.map((p, i) => html`
|
|
109
|
+
<span class="kbd-recorder-key" key=${i}>${prettyKey(p)}</span>
|
|
110
|
+
${i < parts.length - 1
|
|
111
|
+
? html`<span class="kbd-recorder-plus">+</span>`
|
|
112
|
+
: null}
|
|
113
|
+
`)}
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
${captured ? html`
|
|
117
|
+
<div class="kbd-recorder-actions">
|
|
118
|
+
<button class="action small subtle" onClick=${() => setCaptured(null)}>
|
|
119
|
+
Try again
|
|
120
|
+
</button>
|
|
121
|
+
<button class="action small subtle" onClick=${onCancel}>
|
|
122
|
+
Cancel
|
|
123
|
+
</button>
|
|
124
|
+
<button class="action small primary" onClick=${() => onCommit(captured)}>
|
|
125
|
+
Confirm
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
<div class="kbd-recorder-hint">
|
|
129
|
+
<kbd>Enter</kbd> to confirm · <kbd>Esc</kbd> to cancel · press another combo to replace
|
|
130
|
+
</div>
|
|
131
|
+
` : html`
|
|
132
|
+
<div class="kbd-recorder-hint">
|
|
133
|
+
<kbd>Esc</kbd> to cancel · click outside to dismiss
|
|
134
|
+
</div>
|
|
135
|
+
`}
|
|
136
|
+
</div>
|
|
137
|
+
</div>`;
|
|
138
|
+
}
|