@bakapiano/ccsm 0.18.2 → 0.18.4
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 +39 -35
- package/lib/webTerminal.js +11 -0
- package/package.json +1 -1
- package/public/css/responsive.css +16 -8
- package/public/css/sidebar.css +39 -9
- package/public/css/terminals.css +54 -0
- package/public/css/widgets.css +21 -0
- package/public/js/components/MobileNavFab.js +90 -3
- package/public/js/components/Sidebar.js +8 -1
- package/public/js/components/TerminalView.js +86 -12
- package/public/js/pages/RemotePage.js +18 -0
- package/public/js/pages/SessionsPage.js +1 -1
- package/server.js +10 -10
package/lib/tunnel.js
CHANGED
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
// dirs. Returns the absolute path so we can spawn the child regardless
|
|
18
18
|
// of whether the post-install PATH refresh has reached this Node process.
|
|
19
19
|
|
|
20
|
-
const { spawn,
|
|
20
|
+
const { spawn, execFile } = require('node:child_process');
|
|
21
21
|
const path = require('node:path');
|
|
22
22
|
const fs = require('node:fs');
|
|
23
23
|
const os = require('node:os');
|
|
24
|
+
const { promisify } = require('node:util');
|
|
25
|
+
const execFileP = promisify(execFile);
|
|
24
26
|
|
|
25
27
|
const PROVIDERS = {
|
|
26
28
|
cloudflared: {
|
|
@@ -61,19 +63,15 @@ let token = null; // Remote-access bearer token. Null = no remote
|
|
|
61
63
|
function getToken() { return token; }
|
|
62
64
|
function setToken(t) { token = t ? String(t) : null; return token; }
|
|
63
65
|
|
|
64
|
-
function findBinary(provider) {
|
|
66
|
+
async function findBinary(provider) {
|
|
65
67
|
const p = PROVIDERS[provider];
|
|
66
68
|
if (!p) return null;
|
|
67
69
|
// PATH lookup via where.exe — works regardless of how the CLI got
|
|
68
|
-
// installed (winget, choco, manual, in-tree).
|
|
70
|
+
// installed (winget, choco, manual, in-tree). windowsHide stops the
|
|
71
|
+
// conhost window from flashing.
|
|
69
72
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// every 2.5s and probe() (when its 30s cache is cold) fires 4-6
|
|
73
|
-
// execs back-to-back — without this flag the screen popcorns with
|
|
74
|
-
// little windows every cache cycle.
|
|
75
|
-
const out = execFileSync('where.exe', [p.binary], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true })
|
|
76
|
-
.toString().trim().split(/\r?\n/)[0];
|
|
73
|
+
const { stdout } = await execFileP('where.exe', [p.binary], { windowsHide: true });
|
|
74
|
+
const out = String(stdout).trim().split(/\r?\n/)[0];
|
|
77
75
|
if (out && fs.existsSync(out)) return out;
|
|
78
76
|
} catch { /* not on PATH */ }
|
|
79
77
|
// Fall back to known install locations (winget's PATH update doesn't
|
|
@@ -97,20 +95,18 @@ function findBinary(provider) {
|
|
|
97
95
|
return null;
|
|
98
96
|
}
|
|
99
97
|
|
|
100
|
-
function getVersion(provider, exe) {
|
|
98
|
+
async function getVersion(provider, exe) {
|
|
101
99
|
try {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
return out || null;
|
|
100
|
+
const { stdout } = await execFileP(exe, ['--version'], { windowsHide: true });
|
|
101
|
+
return String(stdout).trim().split(/\r?\n/)[0] || null;
|
|
105
102
|
} catch { return null; }
|
|
106
103
|
}
|
|
107
104
|
|
|
108
|
-
function checkDevtunnelLogin(exe) {
|
|
105
|
+
async function checkDevtunnelLogin(exe) {
|
|
109
106
|
try {
|
|
110
|
-
const
|
|
111
|
-
.toString().trim();
|
|
107
|
+
const { stdout } = await execFileP(exe, ['user', 'show'], { windowsHide: true, timeout: 5000 });
|
|
112
108
|
// "Logged in as <email> using <provider>." vs "Not logged in"
|
|
113
|
-
const m =
|
|
109
|
+
const m = String(stdout).trim().match(/Logged in as (\S+)/);
|
|
114
110
|
if (m) return { loggedIn: true, user: m[1] };
|
|
115
111
|
return { loggedIn: false, user: null };
|
|
116
112
|
} catch {
|
|
@@ -128,31 +124,39 @@ const PROBE_TTL_MS = 30_000;
|
|
|
128
124
|
let probeCache = null;
|
|
129
125
|
let probeCacheAt = 0;
|
|
130
126
|
|
|
131
|
-
function probe(force = false) {
|
|
127
|
+
async function probe(force = false) {
|
|
132
128
|
if (!force && probeCache && Date.now() - probeCacheAt < PROBE_TTL_MS) {
|
|
133
129
|
return probeCache;
|
|
134
130
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
131
|
+
// All providers in parallel; within each, --version and the
|
|
132
|
+
// devtunnel `user show` check run together too. Cold probe drops
|
|
133
|
+
// from ~1.5s serial to ~700ms (capped by the slowest exec —
|
|
134
|
+
// typically `devtunnel user show`).
|
|
135
|
+
const ids = Object.keys(PROVIDERS);
|
|
136
|
+
const results = await Promise.all(ids.map(async (id) => {
|
|
137
|
+
const exe = await findBinary(id);
|
|
138
|
+
const p = { installed: !!exe, exe, version: null };
|
|
139
|
+
if (exe) {
|
|
140
|
+
const tasks = [getVersion(id, exe)];
|
|
141
|
+
if (id === 'devtunnel') tasks.push(checkDevtunnelLogin(exe));
|
|
142
|
+
const [version, devUser] = await Promise.all(tasks);
|
|
143
|
+
p.version = version;
|
|
144
|
+
if (devUser) Object.assign(p, devUser);
|
|
141
145
|
}
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
probeCache =
|
|
146
|
+
return [id, p];
|
|
147
|
+
}));
|
|
148
|
+
probeCache = Object.fromEntries(results);
|
|
145
149
|
probeCacheAt = Date.now();
|
|
146
|
-
return
|
|
150
|
+
return probeCache;
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
// Invalidate the cache when callers know the on-disk state likely changed
|
|
150
154
|
// (post-install, post-login, etc.). Next probe() re-shells.
|
|
151
155
|
function invalidateProbe() { probeCache = null; probeCacheAt = 0; }
|
|
152
156
|
|
|
153
|
-
function status() {
|
|
157
|
+
async function status() {
|
|
154
158
|
return {
|
|
155
|
-
providers: probe(),
|
|
159
|
+
providers: await probe(),
|
|
156
160
|
running: !!current,
|
|
157
161
|
provider: current?.provider || null,
|
|
158
162
|
url: current?.url || null,
|
|
@@ -174,10 +178,10 @@ async function start({ provider, port }) {
|
|
|
174
178
|
if (current) throw new Error('tunnel already running');
|
|
175
179
|
const p = PROVIDERS[provider];
|
|
176
180
|
if (!p) throw new Error(`unknown provider: ${provider}`);
|
|
177
|
-
const exe = findBinary(provider);
|
|
181
|
+
const exe = await findBinary(provider);
|
|
178
182
|
if (!exe) throw new Error(`${p.label} is not installed`);
|
|
179
183
|
if (provider === 'devtunnel') {
|
|
180
|
-
const { loggedIn } = checkDevtunnelLogin(exe);
|
|
184
|
+
const { loggedIn } = await checkDevtunnelLogin(exe);
|
|
181
185
|
if (!loggedIn) throw new Error('devtunnel requires login — run `devtunnel user login` first');
|
|
182
186
|
}
|
|
183
187
|
|
|
@@ -211,7 +215,7 @@ async function start({ provider, port }) {
|
|
|
211
215
|
// Wait up to 25s for the URL to show up in stdout.
|
|
212
216
|
const deadline = Date.now() + 25_000;
|
|
213
217
|
while (Date.now() < deadline) {
|
|
214
|
-
if (entry.url) return status();
|
|
218
|
+
if (entry.url) return await status();
|
|
215
219
|
if (!current || current !== entry) {
|
|
216
220
|
throw new Error('tunnel exited before reporting a URL · ' + entry.log.slice(-3).join(' / '));
|
|
217
221
|
}
|
|
@@ -219,7 +223,7 @@ async function start({ provider, port }) {
|
|
|
219
223
|
}
|
|
220
224
|
// Timed out — keep the child alive (the URL might appear later) but
|
|
221
225
|
// tell the caller we don't have one yet.
|
|
222
|
-
return status();
|
|
226
|
+
return await status();
|
|
223
227
|
}
|
|
224
228
|
|
|
225
229
|
function stop() {
|
package/lib/webTerminal.js
CHANGED
|
@@ -138,6 +138,17 @@ function attach(id, ws) {
|
|
|
138
138
|
try { ws.close(4404, 'no such terminal'); } catch {}
|
|
139
139
|
return;
|
|
140
140
|
}
|
|
141
|
+
// Latest-wins: a session has at most one live WebSocket at any
|
|
142
|
+
// moment. The new attach displaces any existing ones — keeps PTY
|
|
143
|
+
// resize semantics unambiguous (each client sends its own dimensions
|
|
144
|
+
// for its own viewport; with two clients fighting, claude's TUI
|
|
145
|
+
// re-renders for whichever sent last and the other side sees a
|
|
146
|
+
// mis-sized layout). Close code 4001 + reason lets the displaced
|
|
147
|
+
// client show a clearer message than the generic "[disconnected]".
|
|
148
|
+
for (const other of entry.sockets) {
|
|
149
|
+
try { other.close(4001, 'displaced by another client'); } catch {}
|
|
150
|
+
}
|
|
151
|
+
entry.sockets.clear();
|
|
141
152
|
entry.sockets.add(ws);
|
|
142
153
|
if (entry.history) {
|
|
143
154
|
try { ws.send(JSON.stringify({ type: 'output', data: scrubReplayResponses(entry.history) })); } catch {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.4",
|
|
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",
|
|
@@ -87,11 +87,13 @@
|
|
|
87
87
|
select { font-size: 16px !important; }
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
/* FAB + backdrop · sit above page content but BELOW dialogs.
|
|
90
|
+
/* FAB + backdrop · sit above page content but BELOW dialogs. The
|
|
91
|
+
`left` and `bottom` inline styles come from MobileNavFab.js (drag-
|
|
92
|
+
persisted), so we don't set defaults here — the component seeds
|
|
93
|
+
them on mount. `touch-action: none` stops the browser from also
|
|
94
|
+
scrolling the page while the user is dragging the FAB. */
|
|
91
95
|
.mobile-nav-fab {
|
|
92
96
|
position: fixed;
|
|
93
|
-
bottom: max(16px, env(safe-area-inset-bottom));
|
|
94
|
-
left: max(16px, env(safe-area-inset-left));
|
|
95
97
|
z-index: 210;
|
|
96
98
|
width: 52px;
|
|
97
99
|
height: 52px;
|
|
@@ -105,12 +107,18 @@
|
|
|
105
107
|
box-shadow:
|
|
106
108
|
0 10px 24px -6px rgba(0,0,0,.28),
|
|
107
109
|
0 2px 4px rgba(0,0,0,.10);
|
|
108
|
-
cursor:
|
|
109
|
-
|
|
110
|
+
cursor: grab;
|
|
111
|
+
touch-action: none;
|
|
112
|
+
user-select: none;
|
|
113
|
+
transition: box-shadow .15s, background .15s;
|
|
110
114
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
/* No translateY on hover — would fight the inline left/bottom we set
|
|
116
|
+
on every pointermove during drag, making the FAB jitter under the
|
|
117
|
+
finger as :hover toggles on/off. Background-only hover for desktop
|
|
118
|
+
pointers; touch never matches :hover. */
|
|
119
|
+
.mobile-nav-fab:hover { background: var(--bg); }
|
|
120
|
+
.mobile-nav-fab:active { cursor: grabbing; }
|
|
121
|
+
.mobile-nav-fab svg { width: 22px; height: 22px; stroke-width: 2; pointer-events: none; }
|
|
114
122
|
/* Open state stays the same paper white — only the icon swaps to X.
|
|
115
123
|
Keeping the colour consistent reads as "same button, different
|
|
116
124
|
mode" instead of two different controls. */
|
package/public/css/sidebar.css
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/* Left collapsible sidebar nav · brand mark · util items · collapse toggle */
|
|
2
2
|
|
|
3
3
|
.sidebar {
|
|
4
|
+
/* One value drives all three "section break" gaps in the sidebar
|
|
5
|
+
column: brand-strip → first nav item, last nav item → "Sessions"
|
|
6
|
+
header, and "Sessions" header → first folder. Bump or shrink to
|
|
7
|
+
adjust how breathy the rail feels. */
|
|
8
|
+
--sidebar-section-gap: var(--s-3);
|
|
4
9
|
position: sticky;
|
|
5
10
|
top: 0;
|
|
6
11
|
height: 100vh;
|
|
@@ -262,7 +267,9 @@
|
|
|
262
267
|
align-items: center;
|
|
263
268
|
padding: 0;
|
|
264
269
|
min-height: 40px;
|
|
265
|
-
|
|
270
|
+
/* Sit flush above the first nav item — no extra breathing room
|
|
271
|
+
between the brand strip and the nav list. */
|
|
272
|
+
margin-bottom: 0;
|
|
266
273
|
}
|
|
267
274
|
.collapse-toggle {
|
|
268
275
|
appearance: none;
|
|
@@ -337,12 +344,18 @@ body.is-resizing-sidebar * {
|
|
|
337
344
|
|
|
338
345
|
/* Compact top nav: smaller height + smaller font, so the folder tree
|
|
339
346
|
below dominates. */
|
|
347
|
+
/* Match the dimensions of .tree-folder-head + .tree-session below so
|
|
348
|
+
the top nav, the folder head, and the session rows form one visually
|
|
349
|
+
continuous column: same left padding (icon at x=8), same icon-label
|
|
350
|
+
gap, same row height, same corner radius. Without this the nav
|
|
351
|
+
icons sit 2px further right than the folder icons and the rows are
|
|
352
|
+
noticeably taller. */
|
|
340
353
|
.sidebar-nav.compact .nav-item {
|
|
341
354
|
font-size: 13px;
|
|
342
|
-
padding: 4px
|
|
343
|
-
min-height:
|
|
344
|
-
gap:
|
|
345
|
-
border-radius:
|
|
355
|
+
padding: 4px 8px;
|
|
356
|
+
min-height: 28px;
|
|
357
|
+
gap: 8px;
|
|
358
|
+
border-radius: 4px;
|
|
346
359
|
position: relative;
|
|
347
360
|
letter-spacing: -0.005em;
|
|
348
361
|
transition: background .14s ease, color .14s ease;
|
|
@@ -367,7 +380,7 @@ body.is-resizing-sidebar * {
|
|
|
367
380
|
/* Tree section header. Looks like codex: uppercase label, small +
|
|
368
381
|
button on hover. */
|
|
369
382
|
.tree {
|
|
370
|
-
margin-top: var(--
|
|
383
|
+
margin-top: var(--sidebar-section-gap);
|
|
371
384
|
display: flex;
|
|
372
385
|
flex-direction: column;
|
|
373
386
|
gap: 2px;
|
|
@@ -394,6 +407,9 @@ body.is-resizing-sidebar * {
|
|
|
394
407
|
font-weight: 500;
|
|
395
408
|
letter-spacing: 0;
|
|
396
409
|
color: var(--ink-mid);
|
|
410
|
+
/* No margin-bottom — let the parent .tree's `gap: 2px` carry the
|
|
411
|
+
space below, matching what sits between folder rows. The big gap
|
|
412
|
+
above "Sessions" still comes from .tree margin-top. */
|
|
397
413
|
}
|
|
398
414
|
.tree-head-action {
|
|
399
415
|
appearance: none;
|
|
@@ -485,7 +501,11 @@ body.is-resizing-sidebar * {
|
|
|
485
501
|
display: none;
|
|
486
502
|
gap: 2px;
|
|
487
503
|
}
|
|
488
|
-
|
|
504
|
+
/* Same touch carve-out as session-actions above — don't reveal folder
|
|
505
|
+
rename/delete on a tap-emulated hover. */
|
|
506
|
+
@media (hover: hover) and (pointer: fine) {
|
|
507
|
+
.tree-folder-head:hover .tree-folder-actions { display: inline-flex; }
|
|
508
|
+
}
|
|
489
509
|
.tree-folder-action {
|
|
490
510
|
appearance: none;
|
|
491
511
|
background: transparent;
|
|
@@ -643,8 +663,18 @@ body.is-resizing-sidebar * {
|
|
|
643
663
|
gap: 2px;
|
|
644
664
|
flex-shrink: 0;
|
|
645
665
|
}
|
|
646
|
-
|
|
647
|
-
|
|
666
|
+
/* Hover-only on real-pointer devices. On touch devices the first tap
|
|
667
|
+
emulates :hover (revealing rename/delete), which means the user has
|
|
668
|
+
to tap a second time to actually open the session. (hover: hover)
|
|
669
|
+
matches a real mouse / trackpad; (pointer: fine) further requires
|
|
670
|
+
precise cursor (so hybrid touch+mouse laptops with a stylus get
|
|
671
|
+
hover but pure-touch phones / tablets do not.) Phones keep the
|
|
672
|
+
timestamp visible and never reveal the action buttons here — they
|
|
673
|
+
can use the kebab in the session pane's top bar instead. */
|
|
674
|
+
@media (hover: hover) and (pointer: fine) {
|
|
675
|
+
.tree-session:hover .tree-session-actions { display: inline-flex; }
|
|
676
|
+
.tree-session:hover .tree-meta { display: none; }
|
|
677
|
+
}
|
|
648
678
|
.tree-session-action {
|
|
649
679
|
appearance: none;
|
|
650
680
|
background: transparent;
|
package/public/css/terminals.css
CHANGED
|
@@ -440,3 +440,57 @@
|
|
|
440
440
|
background: #faf9f5;
|
|
441
441
|
border-color: #faf9f5;
|
|
442
442
|
}
|
|
443
|
+
|
|
444
|
+
/* Displaced state — shown when the server kicks us off because another
|
|
445
|
+
client attached to the same session (latest-wins). Same dark surface
|
|
446
|
+
as terminal-empty so the transition from running terminal → displaced
|
|
447
|
+
doesn't flash a colour change. */
|
|
448
|
+
.terminal-displaced {
|
|
449
|
+
background: #1a1815;
|
|
450
|
+
color: #e8e3d5;
|
|
451
|
+
display: flex;
|
|
452
|
+
align-items: center;
|
|
453
|
+
justify-content: center;
|
|
454
|
+
height: 100%;
|
|
455
|
+
padding: var(--s-5);
|
|
456
|
+
}
|
|
457
|
+
.terminal-displaced-card {
|
|
458
|
+
max-width: 460px;
|
|
459
|
+
text-align: center;
|
|
460
|
+
display: flex;
|
|
461
|
+
flex-direction: column;
|
|
462
|
+
gap: var(--s-3);
|
|
463
|
+
}
|
|
464
|
+
.terminal-displaced-card h2 {
|
|
465
|
+
margin: 0;
|
|
466
|
+
font-size: 16px;
|
|
467
|
+
font-weight: 600;
|
|
468
|
+
color: #faf9f5;
|
|
469
|
+
letter-spacing: -0.005em;
|
|
470
|
+
}
|
|
471
|
+
.terminal-displaced-card p {
|
|
472
|
+
margin: 0;
|
|
473
|
+
font-size: 13px;
|
|
474
|
+
line-height: 1.55;
|
|
475
|
+
color: rgba(232, 227, 213, 0.72);
|
|
476
|
+
}
|
|
477
|
+
.terminal-displaced-actions {
|
|
478
|
+
margin-top: var(--s-2);
|
|
479
|
+
display: flex;
|
|
480
|
+
justify-content: center;
|
|
481
|
+
}
|
|
482
|
+
.terminal-displaced-card .action.primary {
|
|
483
|
+
background: #e8e3d5;
|
|
484
|
+
color: #1a1815;
|
|
485
|
+
border-color: #e8e3d5;
|
|
486
|
+
padding: 9px 20px;
|
|
487
|
+
font-size: 13px;
|
|
488
|
+
}
|
|
489
|
+
.terminal-displaced-card .action.primary:hover {
|
|
490
|
+
background: #faf9f5;
|
|
491
|
+
border-color: #faf9f5;
|
|
492
|
+
}
|
|
493
|
+
.terminal-displaced-hint {
|
|
494
|
+
font-size: 11.5px !important;
|
|
495
|
+
color: rgba(232, 227, 213, 0.45) !important;
|
|
496
|
+
}
|
package/public/css/widgets.css
CHANGED
|
@@ -1724,6 +1724,27 @@
|
|
|
1724
1724
|
word-break: break-all;
|
|
1725
1725
|
}
|
|
1726
1726
|
|
|
1727
|
+
.remote-loading {
|
|
1728
|
+
display: flex;
|
|
1729
|
+
flex-direction: column;
|
|
1730
|
+
align-items: center;
|
|
1731
|
+
justify-content: center;
|
|
1732
|
+
gap: var(--s-3);
|
|
1733
|
+
min-height: 260px;
|
|
1734
|
+
color: var(--ink-muted);
|
|
1735
|
+
font-size: 13px;
|
|
1736
|
+
}
|
|
1737
|
+
.remote-loading p { margin: 0; }
|
|
1738
|
+
.remote-loading-spinner {
|
|
1739
|
+
width: 28px;
|
|
1740
|
+
height: 28px;
|
|
1741
|
+
border-radius: 50%;
|
|
1742
|
+
border: 2px solid var(--border-strong);
|
|
1743
|
+
border-top-color: var(--ink);
|
|
1744
|
+
animation: remote-spin 0.75s linear infinite;
|
|
1745
|
+
}
|
|
1746
|
+
@keyframes remote-spin { to { transform: rotate(360deg); } }
|
|
1747
|
+
|
|
1727
1748
|
.remote-empty {
|
|
1728
1749
|
margin: 0;
|
|
1729
1750
|
font-size: 12.5px;
|
|
@@ -6,24 +6,111 @@
|
|
|
6
6
|
// full-screen overlay. A backdrop captures taps outside the sidebar
|
|
7
7
|
// and dismisses.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// The FAB is draggable — long-press-and-move lets the user reposition
|
|
10
|
+
// it (the default bottom-left can cover page content). A short tap with
|
|
11
|
+
// no drag still toggles the drawer. Position persists in localStorage
|
|
12
|
+
// so the user doesn't have to re-place it each session.
|
|
11
13
|
|
|
12
14
|
import { html } from '../html.js';
|
|
15
|
+
import { useRef, useState, useEffect } from 'preact/hooks';
|
|
13
16
|
import { isMobile, mobileDrawerOpen } from '../state.js';
|
|
14
17
|
import { IconSidebarToggle, IconClose } from '../icons.js';
|
|
15
18
|
|
|
19
|
+
const LS_POS = 'ccsm.fab.pos';
|
|
20
|
+
const FAB_SIZE = 52;
|
|
21
|
+
const SAFE_MARGIN = 8;
|
|
22
|
+
// Movement threshold (px) before pointermove counts as a drag instead
|
|
23
|
+
// of a tap. Below this, pointerup fires the toggle and the FAB stays
|
|
24
|
+
// put — matches what a user expects when they meant to "press" the
|
|
25
|
+
// button, not move it.
|
|
26
|
+
const DRAG_HYST_PX = 6;
|
|
27
|
+
|
|
28
|
+
function loadPos() {
|
|
29
|
+
try {
|
|
30
|
+
const raw = localStorage.getItem(LS_POS);
|
|
31
|
+
if (!raw) return null;
|
|
32
|
+
const p = JSON.parse(raw);
|
|
33
|
+
if (typeof p.left === 'number' && typeof p.bottom === 'number') return p;
|
|
34
|
+
} catch {}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
function savePos(p) {
|
|
38
|
+
try { localStorage.setItem(LS_POS, JSON.stringify(p)); } catch {}
|
|
39
|
+
}
|
|
40
|
+
function clampPos(p) {
|
|
41
|
+
// Re-clamp on every render so a position saved at one viewport size
|
|
42
|
+
// doesn't trap the FAB off-screen at a smaller size (rotation,
|
|
43
|
+
// resize, etc.).
|
|
44
|
+
const vw = window.innerWidth;
|
|
45
|
+
const vh = window.innerHeight;
|
|
46
|
+
return {
|
|
47
|
+
left: Math.max(SAFE_MARGIN, Math.min(vw - FAB_SIZE - SAFE_MARGIN, p.left)),
|
|
48
|
+
bottom: Math.max(SAFE_MARGIN, Math.min(vh - FAB_SIZE - SAFE_MARGIN, p.bottom)),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
16
52
|
export function MobileNavFab() {
|
|
17
53
|
if (!isMobile.value) return null;
|
|
18
54
|
const open = mobileDrawerOpen.value;
|
|
55
|
+
const [pos, setPos] = useState(() => loadPos() || { left: 16, bottom: 24 });
|
|
56
|
+
const dragRef = useRef({ start: null, moved: false });
|
|
57
|
+
|
|
58
|
+
// Re-clamp on viewport changes so a rotation doesn't strand the FAB
|
|
59
|
+
// beyond the new edge.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const onResize = () => setPos((p) => clampPos(p));
|
|
62
|
+
window.addEventListener('resize', onResize);
|
|
63
|
+
return () => window.removeEventListener('resize', onResize);
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const onPointerDown = (ev) => {
|
|
67
|
+
ev.currentTarget.setPointerCapture(ev.pointerId);
|
|
68
|
+
dragRef.current = {
|
|
69
|
+
start: { x: ev.clientX, y: ev.clientY, fromPos: pos },
|
|
70
|
+
moved: false,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
const onPointerMove = (ev) => {
|
|
74
|
+
const d = dragRef.current;
|
|
75
|
+
if (!d.start) return;
|
|
76
|
+
const dx = ev.clientX - d.start.x;
|
|
77
|
+
const dy = ev.clientY - d.start.y;
|
|
78
|
+
if (!d.moved && Math.hypot(dx, dy) < DRAG_HYST_PX) return;
|
|
79
|
+
d.moved = true;
|
|
80
|
+
// Stop the page from also scrolling while we drag.
|
|
81
|
+
ev.preventDefault();
|
|
82
|
+
setPos(clampPos({
|
|
83
|
+
// bottom = distance from bottom edge to the FAB's bottom edge.
|
|
84
|
+
// Pointer moved DOWN (+dy) → FAB moves down → bottom decreases.
|
|
85
|
+
left: d.start.fromPos.left + dx,
|
|
86
|
+
bottom: d.start.fromPos.bottom - dy,
|
|
87
|
+
}));
|
|
88
|
+
};
|
|
89
|
+
const onPointerUp = (ev) => {
|
|
90
|
+
const d = dragRef.current;
|
|
91
|
+
try { ev.currentTarget.releasePointerCapture(ev.pointerId); } catch {}
|
|
92
|
+
dragRef.current = { start: null, moved: false };
|
|
93
|
+
if (d.moved) {
|
|
94
|
+
// Drag finished — persist the new resting spot.
|
|
95
|
+
savePos(pos);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// No appreciable movement → treat as tap.
|
|
99
|
+
mobileDrawerOpen.value = !open;
|
|
100
|
+
};
|
|
101
|
+
|
|
19
102
|
return html`
|
|
20
103
|
${open ? html`
|
|
21
104
|
<div class="mobile-nav-backdrop"
|
|
22
105
|
onClick=${() => { mobileDrawerOpen.value = false; }} />
|
|
23
106
|
` : null}
|
|
24
107
|
<button class=${`mobile-nav-fab${open ? ' is-open' : ''}`}
|
|
108
|
+
style=${`left: ${pos.left}px; bottom: ${pos.bottom}px;`}
|
|
25
109
|
aria-label=${open ? 'close navigation' : 'open navigation'}
|
|
26
|
-
|
|
110
|
+
onPointerDown=${onPointerDown}
|
|
111
|
+
onPointerMove=${onPointerMove}
|
|
112
|
+
onPointerUp=${onPointerUp}
|
|
113
|
+
onPointerCancel=${onPointerUp}>
|
|
27
114
|
${open ? html`<${IconClose} />` : html`<${IconSidebarToggle} />`}
|
|
28
115
|
</button>`;
|
|
29
116
|
}
|
|
@@ -140,9 +140,16 @@ function SessionRow({ s, folderId, siblingIds }) {
|
|
|
140
140
|
.catch((e) => setToast(e.message, 'error'));
|
|
141
141
|
};
|
|
142
142
|
|
|
143
|
+
// Skip the HTML5 drag affordance on touch devices — `draggable=true`
|
|
144
|
+
// makes mobile browsers interpret the first tap as a drag-start
|
|
145
|
+
// gesture, swallowing the click event entirely. The user then needs
|
|
146
|
+
// a second tap to navigate. Touch users don't reorder sessions by
|
|
147
|
+
// drag anyway; we'd add a dedicated "move to folder" affordance if
|
|
148
|
+
// anyone asked.
|
|
149
|
+
const touchDevice = isMobile.value || (typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches);
|
|
143
150
|
return html`
|
|
144
151
|
<div class=${`tree-session${isActive ? ' is-active' : ''}${running ? ' is-running' : ' is-stopped'}${running && s.activity === 'working' ? ' is-working' : ''}${showInsertLine ? ' is-reorder-target' : ''}`}
|
|
145
|
-
draggable=${
|
|
152
|
+
draggable=${!touchDevice}
|
|
146
153
|
onDragStart=${onDragStart}
|
|
147
154
|
onDragEnd=${onDragEnd}
|
|
148
155
|
onDragOver=${onRowDragOver}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// output frames into xterm. Disposes everything on unmount or id change.
|
|
4
4
|
|
|
5
5
|
import { html } from '../html.js';
|
|
6
|
-
import { useEffect, useRef } from 'preact/hooks';
|
|
6
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
7
7
|
import { Terminal } from '@xterm/xterm';
|
|
8
8
|
import { FitAddon } from '@xterm/addon-fit';
|
|
9
9
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
@@ -32,10 +32,17 @@ const THEME = {
|
|
|
32
32
|
white: '#e8e3d5', brightWhite: '#faf9f5',
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
export function TerminalView({ terminalId }) {
|
|
35
|
+
export function TerminalView({ terminalId, cliType }) {
|
|
36
36
|
const hostRef = useRef(null);
|
|
37
37
|
const termRef = useRef(null);
|
|
38
38
|
const wsRef = useRef(null);
|
|
39
|
+
// Set when ws.onclose receives our custom "displaced by another
|
|
40
|
+
// client" code (4001) from lib/webTerminal.js's latest-wins policy.
|
|
41
|
+
// Renders a full-pane prompt with a "Take it back" button that bumps
|
|
42
|
+
// reattachNonce → useEffect re-runs → new WS, displacing whoever
|
|
43
|
+
// currently holds the session.
|
|
44
|
+
const [displaced, setDisplaced] = useState(false);
|
|
45
|
+
const [reattachNonce, setReattach] = useState(0);
|
|
39
46
|
|
|
40
47
|
useEffect(() => {
|
|
41
48
|
if (!terminalId || !hostRef.current) return;
|
|
@@ -148,8 +155,19 @@ export function TerminalView({ terminalId }) {
|
|
|
148
155
|
term.write(`\r\n\x1b[2m[process exited · code ${frame.code}]\x1b[0m\r\n`);
|
|
149
156
|
}
|
|
150
157
|
};
|
|
151
|
-
ws.onclose = () => {
|
|
152
|
-
|
|
158
|
+
ws.onclose = (ev) => {
|
|
159
|
+
// Server uses code 4001 + reason "displaced by another client"
|
|
160
|
+
// when a fresh attach takes over the session (latest-wins policy
|
|
161
|
+
// in lib/webTerminal.js's attach). We replace the terminal with
|
|
162
|
+
// a full-pane prompt + Take it back button via setDisplaced(true).
|
|
163
|
+
// Generic disconnects (network blip, server restart, PTY exit)
|
|
164
|
+
// get the dim inline notice as before — those usually self-heal
|
|
165
|
+
// and aren't worth a modal.
|
|
166
|
+
if (ev && ev.code === 4001) {
|
|
167
|
+
setDisplaced(true);
|
|
168
|
+
} else {
|
|
169
|
+
term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
|
|
170
|
+
}
|
|
153
171
|
};
|
|
154
172
|
|
|
155
173
|
const onData = (data) => {
|
|
@@ -262,12 +280,18 @@ export function TerminalView({ terminalId }) {
|
|
|
262
280
|
// Shift+Enter / Ctrl+Enter → insert literal newline, don't submit.
|
|
263
281
|
// Background: xterm.js encodes BOTH plain Enter and Shift+Enter and
|
|
264
282
|
// Ctrl+Enter as \r (0x0D / CR). The kitty keyboard / win32 input
|
|
265
|
-
// protocols
|
|
266
|
-
//
|
|
267
|
-
//
|
|
283
|
+
// protocols WOULD distinguish them, but they're opt-in by the
|
|
284
|
+
// running app and most CLIs don't enable them, so we never get the
|
|
285
|
+
// distinction "for free". Each CLI handles modified-Enter differently:
|
|
286
|
+
//
|
|
287
|
+
// claude · expects a literal LF (0x0A) — its prompt treats \n
|
|
288
|
+
// as "insert newline", \r as "submit". Workaround = '\n'.
|
|
289
|
+
// codex / others · use ratatui or similar TUI libs that decode
|
|
290
|
+
// the kitty keyboard CSI u sequence. We synthesise it
|
|
291
|
+
// explicitly: `CSI 13 ; <mod> u` where mod = 2 for
|
|
292
|
+
// Shift, 5 for Ctrl. That maps to the exact key+mod
|
|
293
|
+
// the user pressed and ratatui inserts a newline.
|
|
268
294
|
//
|
|
269
|
-
// Send the LF (0x0A) explicitly. Claude code (and most modern TUIs)
|
|
270
|
-
// treat \n inside a prompt as a literal newline insert, \r as submit.
|
|
271
295
|
// Alt+Enter already works (xterm sends \x1b\r → meta-enter) so we
|
|
272
296
|
// leave that alone.
|
|
273
297
|
const onShiftEnter = (ev) => {
|
|
@@ -275,11 +299,21 @@ export function TerminalView({ terminalId }) {
|
|
|
275
299
|
if (!(ev.shiftKey || ev.ctrlKey)) return;
|
|
276
300
|
if (ev.metaKey || ev.altKey) return;
|
|
277
301
|
if (!isOurs()) return;
|
|
302
|
+
// claude → LF (its prompt parses \n as insert-newline).
|
|
303
|
+
// others → ESC+CR i.e. Alt+Enter. crossterm (codex/copilot
|
|
304
|
+
// TUI libs) decodes ESC-prefixed sequences as Alt-modified
|
|
305
|
+
// without needing the kitty keyboard protocol enabled — and
|
|
306
|
+
// codex's default keymap binds Alt+Enter to insert_newline
|
|
307
|
+
// alongside Shift+Enter (see openai/codex
|
|
308
|
+
// codex-rs/tui/src/keymap.rs L904-909). The kitty CSI u
|
|
309
|
+
// sequence we tried first only works after the app has
|
|
310
|
+
// negotiated kitty mode, which codex doesn't do by default.
|
|
311
|
+
const data = cliType === 'claude' ? '\n' : '\x1b\r';
|
|
278
312
|
ev.preventDefault();
|
|
279
313
|
ev.stopPropagation();
|
|
280
314
|
ev.stopImmediatePropagation();
|
|
281
315
|
if (ws.readyState === 1) {
|
|
282
|
-
ws.send(JSON.stringify({ type: 'input', data
|
|
316
|
+
ws.send(JSON.stringify({ type: 'input', data }));
|
|
283
317
|
}
|
|
284
318
|
};
|
|
285
319
|
document.addEventListener('keydown', onShiftEnter, true);
|
|
@@ -328,10 +362,50 @@ export function TerminalView({ terminalId }) {
|
|
|
328
362
|
termRef.current = null;
|
|
329
363
|
wsRef.current = null;
|
|
330
364
|
};
|
|
331
|
-
}, [terminalId]);
|
|
365
|
+
}, [terminalId, reattachNonce]);
|
|
332
366
|
|
|
333
367
|
if (!terminalId) {
|
|
334
368
|
return html`<div class="terminal-empty">Select a terminal on the left, or launch a new one.</div>`;
|
|
335
369
|
}
|
|
336
|
-
|
|
370
|
+
if (displaced) {
|
|
371
|
+
// Distinct key (and a non-div tag) forces Preact's reconciler to
|
|
372
|
+
// UNMOUNT the host <div> and mount a fresh element. Without this,
|
|
373
|
+
// Preact reuses the same DOM node, only flipping its className —
|
|
374
|
+
// and the xterm canvases stay parented inside, visible behind our
|
|
375
|
+
// overlay text. Re-mount on reattach: bumping reattachNonce reruns
|
|
376
|
+
// the effect with a fresh Terminal + WebSocket pair, which the
|
|
377
|
+
// server's latest-wins gate handles by displacing the current
|
|
378
|
+
// holder.
|
|
379
|
+
return html`
|
|
380
|
+
<section key="displaced" class="terminal-displaced">
|
|
381
|
+
<div class="terminal-displaced-card">
|
|
382
|
+
<h2>Another device picked up this session</h2>
|
|
383
|
+
<p>
|
|
384
|
+
Only one client at a time can attach. Your terminal here was
|
|
385
|
+
closed when another browser opened this session — its keystrokes
|
|
386
|
+
and resize events would otherwise fight yours.
|
|
387
|
+
</p>
|
|
388
|
+
<div class="terminal-displaced-actions">
|
|
389
|
+
<button class="action primary"
|
|
390
|
+
onClick=${() => {
|
|
391
|
+
// Clear displaced FIRST so the next render swaps the
|
|
392
|
+
// overlay out for the host div — that's the only
|
|
393
|
+
// way hostRef.current populates. Then bump the nonce
|
|
394
|
+
// so the effect re-runs with the freshly-mounted
|
|
395
|
+
// host and opens a new WS. Doing both in one tick
|
|
396
|
+
// batches the state updates; React renders, mounts
|
|
397
|
+
// the host, then flushes effects.
|
|
398
|
+
setDisplaced(false);
|
|
399
|
+
setReattach((n) => n + 1);
|
|
400
|
+
}}>
|
|
401
|
+
Take it back
|
|
402
|
+
</button>
|
|
403
|
+
</div>
|
|
404
|
+
<p class="terminal-displaced-hint">
|
|
405
|
+
Taking it back will close the other client the same way.
|
|
406
|
+
</p>
|
|
407
|
+
</div>
|
|
408
|
+
</section>`;
|
|
409
|
+
}
|
|
410
|
+
return html`<div key="host" ref=${hostRef} class="terminal-host"></div>`;
|
|
337
411
|
}
|
|
@@ -124,6 +124,12 @@ export function RemotePage() {
|
|
|
124
124
|
const [token, setTokenLocal] = useState('');
|
|
125
125
|
const [busy, setBusy] = useState(false);
|
|
126
126
|
const [deviceList, setDeviceList] = useState([]);
|
|
127
|
+
// First /api/tunnel/status round-trip is the slow one — even with
|
|
128
|
+
// 30s server-side cache + parallel probe, a cold call shells out to
|
|
129
|
+
// where.exe / --version / `devtunnel user show` and adds ~700ms.
|
|
130
|
+
// We hide the form behind a spinner during that window so the user
|
|
131
|
+
// doesn't see empty radios + placeholders that suddenly fill in.
|
|
132
|
+
const [loading, setLoading] = useState(true);
|
|
127
133
|
const pollRef = useRef(null);
|
|
128
134
|
|
|
129
135
|
async function refresh() {
|
|
@@ -143,6 +149,7 @@ export function RemotePage() {
|
|
|
143
149
|
return cur || 'cloudflared';
|
|
144
150
|
});
|
|
145
151
|
} catch (e) { setToast(`status load failed · ${e.message}`, 'error'); }
|
|
152
|
+
finally { setLoading(false); }
|
|
146
153
|
}
|
|
147
154
|
|
|
148
155
|
useEffect(() => {
|
|
@@ -245,6 +252,17 @@ export function RemotePage() {
|
|
|
245
252
|
const cf = status?.providers?.cloudflared;
|
|
246
253
|
const dt = status?.providers?.devtunnel;
|
|
247
254
|
|
|
255
|
+
if (loading) {
|
|
256
|
+
return html`
|
|
257
|
+
<${PageTitleBar} title="Remote" />
|
|
258
|
+
<div class="settings-scroll">
|
|
259
|
+
<div class="remote-loading">
|
|
260
|
+
<div class="remote-loading-spinner" aria-hidden="true"></div>
|
|
261
|
+
<p>Probing tunnel providers…</p>
|
|
262
|
+
</div>
|
|
263
|
+
</div>`;
|
|
264
|
+
}
|
|
265
|
+
|
|
248
266
|
return html`
|
|
249
267
|
<${PageTitleBar} title="Remote" />
|
|
250
268
|
<div class="settings-scroll">
|
|
@@ -150,7 +150,7 @@ export function SessionsPage() {
|
|
|
150
150
|
<div class="session-pane">
|
|
151
151
|
<div class="session-pane-body">
|
|
152
152
|
${running
|
|
153
|
-
? html`<${TerminalView} terminalId=${session.id} />`
|
|
153
|
+
? html`<${TerminalView} terminalId=${session.id} cliType=${cli?.type} />`
|
|
154
154
|
: html`
|
|
155
155
|
<div class="terminal-empty">
|
|
156
156
|
${resumeError ? html`
|
package/server.js
CHANGED
|
@@ -1035,9 +1035,9 @@ app.post('/api/shutdown', (_req, res) => {
|
|
|
1035
1035
|
// the chosen tunnel CLI. URL appears asynchronously in the CLI's
|
|
1036
1036
|
// stdout; lib/tunnel parses it. /status returns the latest snapshot
|
|
1037
1037
|
// for the page to poll.
|
|
1038
|
-
app.get('/api/tunnel/status', (_req, res) => {
|
|
1039
|
-
res.json(tunnel.status());
|
|
1040
|
-
});
|
|
1038
|
+
app.get('/api/tunnel/status', asyncH(async (_req, res) => {
|
|
1039
|
+
res.json(await tunnel.status());
|
|
1040
|
+
}));
|
|
1041
1041
|
app.post('/api/tunnel/start', asyncH(async (req, res) => {
|
|
1042
1042
|
const { provider, token } = req.body || {};
|
|
1043
1043
|
if (!token || String(token).length < 8) {
|
|
@@ -1048,20 +1048,20 @@ app.post('/api/tunnel/start', asyncH(async (req, res) => {
|
|
|
1048
1048
|
const result = await tunnel.start({ provider, port: currentPort });
|
|
1049
1049
|
res.json(result);
|
|
1050
1050
|
} catch (e) {
|
|
1051
|
-
res.status(400).json({ error: e.message, providers: tunnel.probe() });
|
|
1051
|
+
res.status(400).json({ error: e.message, providers: await tunnel.probe().catch(() => ({})) });
|
|
1052
1052
|
}
|
|
1053
1053
|
}));
|
|
1054
|
-
app.post('/api/tunnel/stop', (_req, res) => {
|
|
1054
|
+
app.post('/api/tunnel/stop', asyncH(async (_req, res) => {
|
|
1055
1055
|
const stopped = tunnel.stop();
|
|
1056
|
-
res.json({ stopped, ...tunnel.status() });
|
|
1057
|
-
});
|
|
1058
|
-
app.post('/api/tunnel/token', (req, res) => {
|
|
1056
|
+
res.json({ stopped, ...(await tunnel.status()) });
|
|
1057
|
+
}));
|
|
1058
|
+
app.post('/api/tunnel/token', asyncH(async (req, res) => {
|
|
1059
1059
|
// Bare token update without touching the running tunnel.
|
|
1060
1060
|
// POST { token: '' } to clear and disable remote auth.
|
|
1061
1061
|
const t = (req.body && req.body.token) || '';
|
|
1062
1062
|
tunnel.setToken(t);
|
|
1063
|
-
res.json(tunnel.status());
|
|
1064
|
-
});
|
|
1063
|
+
res.json(await tunnel.status());
|
|
1064
|
+
}));
|
|
1065
1065
|
app.post('/api/tunnel/install', asyncH(async (req, res) => {
|
|
1066
1066
|
const { provider } = req.body || {};
|
|
1067
1067
|
try {
|