@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 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, execFileSync } = require('node:child_process');
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
- // windowsHide: true stops each execFileSync from briefly flashing
71
- // a black conhost window. The Remote page polls /api/tunnel/status
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 out = execFileSync(exe, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
103
- .toString().trim().split(/\r?\n/)[0];
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 out = execFileSync(exe, ['user', 'show'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, windowsHide: true })
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 = out.match(/Logged in as (\S+)/);
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
- const out = {};
136
- for (const id of Object.keys(PROVIDERS)) {
137
- const exe = findBinary(id);
138
- const p = { installed: !!exe, exe, version: exe ? getVersion(id, exe) : null };
139
- if (id === 'devtunnel' && exe) {
140
- Object.assign(p, checkDevtunnelLogin(exe));
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
- out[id] = p;
143
- }
144
- probeCache = out;
146
+ return [id, p];
147
+ }));
148
+ probeCache = Object.fromEntries(results);
145
149
  probeCacheAt = Date.now();
146
- return out;
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() {
@@ -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.2",
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: pointer;
109
- transition: transform .15s, box-shadow .15s, background .15s;
110
+ cursor: grab;
111
+ touch-action: none;
112
+ user-select: none;
113
+ transition: box-shadow .15s, background .15s;
110
114
  }
111
- .mobile-nav-fab:hover { transform: translateY(-1px); background: var(--bg); }
112
- .mobile-nav-fab:active { transform: translateY(0); }
113
- .mobile-nav-fab svg { width: 22px; height: 22px; stroke-width: 2; }
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. */
@@ -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
- margin-bottom: var(--s-2);
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 10px;
343
- min-height: 30px;
344
- gap: 10px;
345
- border-radius: 6px;
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(--s-3);
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
- .tree-folder-head:hover .tree-folder-actions { display: inline-flex; }
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
- .tree-session:hover .tree-session-actions { display: inline-flex; }
647
- .tree-session:hover .tree-meta { display: none; }
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;
@@ -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
+ }
@@ -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
- // Visible only when isMobile signal is truesaves a render branch
10
- // elsewhere.
9
+ // The FAB is draggablelong-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
- onClick=${() => { mobileDrawerOpen.value = !open; }}>
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=${true}
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
- term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
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 (enabled in vtExtensions above) WOULD distinguish them,
266
- // but they're opt-in by the running app claude code doesn't enable
267
- // either, so we never get the distinction "for free".
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: '\n' }));
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
- return html`<div ref=${hostRef} class="terminal-host"></div>`;
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 {