@bakapiano/ccsm 0.17.10 → 0.18.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.
@@ -1,28 +1,84 @@
1
- // One source of truth for "where is the ccsm backend reachable".
1
+ // One source of truth for "where is the ccsm backend reachable"
2
+ // and "what auth token (if any) do we attach to every request".
2
3
  //
3
- // localhost / 127.0.0.1same-origin (page IS the backend)
4
- // everything else → http://localhost:7777 (hosted frontend
5
- // talks to the user's local backend via CORS)
4
+ // localhost / 127.0.0.1 same-origin (page IS the backend)
5
+ // bakapiano.github.io http://localhost:7777 (the hosted
6
+ // frontend talks to the user's local
7
+ // backend via CORS)
8
+ // anything else (tunnel domain) same-origin (the local backend is
9
+ // serving this frontend over the
10
+ // tunnel; API calls go to the same
11
+ // tunnel URL automatically)
6
12
  //
7
13
  // httpBase is used by fetch(); wsBase is used by WebSocket constructions.
8
14
  // Keep both as functions rather than constants so the values reflect
9
15
  // `location.*` at call time (matters for tests / route changes).
10
16
 
17
+ const HOSTED_HOST = 'bakapiano.github.io';
18
+
11
19
  function isLocal() {
12
20
  return location.hostname === 'localhost' || location.hostname === '127.0.0.1';
13
21
  }
22
+ function isHosted() {
23
+ return location.hostname === HOSTED_HOST;
24
+ }
14
25
 
15
26
  export function httpBase() {
16
- return isLocal() ? '' : 'http://localhost:7777';
27
+ if (isHosted()) return 'http://localhost:7777';
28
+ // Local OR tunnel-served — both same-origin.
29
+ return '';
17
30
  }
18
31
 
19
32
  export function wsBase() {
20
- if (isLocal()) {
21
- return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
22
- }
23
- return 'ws://localhost:7777';
33
+ if (isHosted()) return 'ws://localhost:7777';
34
+ return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
24
35
  }
25
36
 
26
37
  export function isHostedFrontend() {
27
- return !isLocal();
38
+ return isHosted();
39
+ }
40
+ // True when the page is being served via a remote tunnel — neither the
41
+ // host machine itself (localhost) nor the GH-Pages router. Used to gate
42
+ // off "wake backend" affordances that only work locally.
43
+ export function isRemoteAccess() {
44
+ return !isLocal() && !isHosted();
45
+ }
46
+
47
+ // ── Remote-access bearer token ────────────────────────────────────
48
+ // Persisted in localStorage so it survives reloads on whatever device
49
+ // loaded the share URL. main.js captures a fresh token from `?token=`
50
+ // on first arrival and stashes it via setToken(), then strips the
51
+ // query string from the URL so the secret doesn't sit in the address
52
+ // bar / browser history.
53
+ const LS_KEY = 'ccsm.token';
54
+
55
+ export function getToken() {
56
+ try { return localStorage.getItem(LS_KEY) || null; } catch { return null; }
57
+ }
58
+ export function setToken(t) {
59
+ try {
60
+ if (t) localStorage.setItem(LS_KEY, t);
61
+ else localStorage.removeItem(LS_KEY);
62
+ } catch {}
63
+ }
64
+
65
+ // ── Device id ─────────────────────────────────────────────────────
66
+ // Per-browser-profile UUID that identifies this device to the host
67
+ // machine for the approval flow. Generated once, persisted in
68
+ // localStorage, sent on every API call as X-Device-Id. The host pairs
69
+ // the id with the User-Agent the server records on first sight, so
70
+ // the approval UI can show "iPhone · Safari" instead of a raw uuid.
71
+ const LS_DEVICE = 'ccsm.deviceId';
72
+
73
+ export function getDeviceId() {
74
+ try {
75
+ let id = localStorage.getItem(LS_DEVICE);
76
+ if (!id) {
77
+ id = (crypto.randomUUID && crypto.randomUUID()) || (Math.random().toString(36).slice(2) + Date.now().toString(36));
78
+ localStorage.setItem(LS_DEVICE, id);
79
+ }
80
+ return id;
81
+ } catch {
82
+ return null;
83
+ }
28
84
  }
@@ -1,12 +1,19 @@
1
1
  import { html } from '../html.js';
2
- import { activeTab } from '../state.js';
2
+ import { activeTab, selectTab } from '../state.js';
3
+ import { useEffect } from 'preact/hooks';
4
+ import { isRemoteAccess } from '../backend.js';
5
+ import { PageTitleBar } from './PageTitleBar.js';
3
6
  import { Sidebar } from './Sidebar.js';
4
7
  import { Toast } from './Toast.js';
5
8
  import { DialogHost } from './DialogHost.js';
6
9
  import { HealthOverlay } from './HealthOverlay.js';
10
+ import { PendingApprovalOverlay } from './PendingApprovalOverlay.js';
11
+ import { MobileNavFab } from './MobileNavFab.js';
12
+ import { isMobile, mobileDrawerOpen } from '../state.js';
7
13
  import { SessionsPage } from '../pages/SessionsPage.js';
8
14
  import { LaunchPage } from '../pages/LaunchPage.js';
9
15
  import { ConfigurePage } from '../pages/ConfigurePage.js';
16
+ import { RemotePage } from '../pages/RemotePage.js';
10
17
  import { AboutPage } from '../pages/AboutPage.js';
11
18
 
12
19
  function Panel({ name, children }) {
@@ -14,22 +21,51 @@ function Panel({ name, children }) {
14
21
  return html`<section class="tab-panel" data-panel=${name} data-active=${active || null}>${children}</section>`;
15
22
  }
16
23
 
24
+ // Static placeholder for #remote on tunnel-served pages. Remote / device
25
+ // / tunnel management is loopback-only — the server returns 403 on
26
+ // every relevant endpoint — so even if a user navigates here via URL
27
+ // hash we render a clear "host machine only" message instead of a
28
+ // broken RemotePage spamming the console.
29
+ function RemoteHostOnlyPanel() {
30
+ useEffect(() => {
31
+ // Bounce back to whatever tab they were on before, after a brief
32
+ // moment so the message is readable.
33
+ const t = setTimeout(() => selectTab('sessions'), 2500);
34
+ return () => clearTimeout(t);
35
+ }, []);
36
+ return html`
37
+ <${PageTitleBar} title="Remote" />
38
+ <div class="settings-scroll">
39
+ <p class="remote-empty" style="margin-top:var(--s-6)">
40
+ Remote management is only available on the host machine.
41
+ Bouncing back to Sessions…
42
+ </p>
43
+ </div>`;
44
+ }
45
+
17
46
  export function App() {
18
47
  const tab = activeTab.value;
48
+ const remoteLocked = tab === 'remote' && isRemoteAccess();
49
+ const mobile = isMobile.value;
50
+ const drawer = mobileDrawerOpen.value;
19
51
 
20
52
  return html`
21
- <div class="app">
53
+ <div class=${`app${mobile ? ' is-mobile' : ''}${mobile && drawer ? ' drawer-open' : ''}`}>
22
54
  <${Sidebar} />
23
55
  <main class="main">
24
56
  <div class="content">
25
57
  ${tab === 'sessions' ? html`<${Panel} name="sessions"><${SessionsPage} /></${Panel}>` : null}
26
58
  ${tab === 'launch' ? html`<${Panel} name="launch"><${LaunchPage} /></${Panel}>` : null}
27
59
  ${tab === 'configure' ? html`<${Panel} name="configure"><${ConfigurePage} /></${Panel}>` : null}
60
+ ${tab === 'remote' && !remoteLocked ? html`<${Panel} name="remote"><${RemotePage} /></${Panel}>` : null}
61
+ ${remoteLocked ? html`<${Panel} name="remote"><${RemoteHostOnlyPanel} /></${Panel}>` : null}
28
62
  ${tab === 'about' ? html`<${Panel} name="about"><${AboutPage} /></${Panel}>` : null}
29
63
  </div>
30
64
  </main>
31
65
  <${Toast} />
32
66
  <${DialogHost} />
33
67
  <${HealthOverlay} />
68
+ <${PendingApprovalOverlay} />
69
+ <${MobileNavFab} />
34
70
  </div>`;
35
71
  }
@@ -21,6 +21,7 @@ import { useEffect } from 'preact/hooks';
21
21
  import { serverHealth, hasBootedOnline } from '../state.js';
22
22
  import { pollHealth, refreshAll } from '../api.js';
23
23
  import { BrandMark } from '../icons.js';
24
+ import { isRemoteAccess } from '../backend.js';
24
25
 
25
26
  const THRESHOLD = 3; // failures before we switch from "checking" to "not running"
26
27
  const FAST_POLL_MS = 1500;
@@ -66,6 +67,17 @@ export function HealthOverlay() {
66
67
  <p class="offline-copy">
67
68
  ${count === 0 ? 'Probing localhost:7777.' : `${count} attempt${count > 1 ? 's' : ''}. Hang tight.`}
68
69
  </p>
70
+ ` : isRemoteAccess() ? html`
71
+ <h1 class="offline-title">Host machine offline</h1>
72
+ <p class="offline-copy">
73
+ The ccsm backend you connected to over the tunnel isn't reachable.
74
+ Only the operator at the host machine can restart it — the tunnel
75
+ URL is dead until ccsm is running there again.
76
+ </p>
77
+ <p class="offline-copy" style="margin-top:8px;font-size:12px;color:var(--ink-muted)">
78
+ We'll keep polling and reconnect automatically as soon as the
79
+ backend comes back.
80
+ </p>
69
81
  ` : html`
70
82
  <h1 class="offline-title">Backend not running</h1>
71
83
  <p class="offline-copy">
@@ -0,0 +1,29 @@
1
+ // Phone-only navigation affordance.
2
+ //
3
+ // On viewports ≤ 640px the sidebar is hidden via CSS (.is-mobile body
4
+ // class). Instead, a circular floating button sits bottom-left; tapping
5
+ // it sets mobileDrawerOpen, which the sidebar reads to flip into a
6
+ // full-screen overlay. A backdrop captures taps outside the sidebar
7
+ // and dismisses.
8
+ //
9
+ // Visible only when isMobile signal is true — saves a render branch
10
+ // elsewhere.
11
+
12
+ import { html } from '../html.js';
13
+ import { isMobile, mobileDrawerOpen } from '../state.js';
14
+ import { IconSidebarToggle, IconClose } from '../icons.js';
15
+
16
+ export function MobileNavFab() {
17
+ if (!isMobile.value) return null;
18
+ const open = mobileDrawerOpen.value;
19
+ return html`
20
+ ${open ? html`
21
+ <div class="mobile-nav-backdrop"
22
+ onClick=${() => { mobileDrawerOpen.value = false; }} />
23
+ ` : null}
24
+ <button class=${`mobile-nav-fab${open ? ' is-open' : ''}`}
25
+ aria-label=${open ? 'close navigation' : 'open navigation'}
26
+ onClick=${() => { mobileDrawerOpen.value = !open; }}>
27
+ ${open ? html`<${IconClose} />` : html`<${IconSidebarToggle} />`}
28
+ </button>`;
29
+ }
@@ -0,0 +1,86 @@
1
+ // Full-screen blocker shown on the remote browser while waiting for
2
+ // the host to approve this device. Triggered by api.js setting the
3
+ // pendingDevice signal in response to a 403 {pending:true}.
4
+ //
5
+ // While visible, polls /api/devices/me every 3s. When the server
6
+ // flips status to 'approved', the next polled api() call clears
7
+ // pendingDevice (api.js does that on any 2xx response), the overlay
8
+ // unmounts, and the rest of the app keeps loading.
9
+ //
10
+ // Reuses .offline-overlay / .offline-card classes so styling matches
11
+ // the existing HealthOverlay's blocking modal aesthetic.
12
+
13
+ import { html } from '../html.js';
14
+ import { useEffect } from 'preact/hooks';
15
+ import { api, pendingDevice, loadConfig, refreshAll } from '../api.js';
16
+ import { BrandMark } from '../icons.js';
17
+
18
+ const POLL_MS = 3000;
19
+
20
+ export function PendingApprovalOverlay() {
21
+ const p = pendingDevice.value;
22
+
23
+ useEffect(() => {
24
+ if (!p) return;
25
+ let stopped = false;
26
+ const tick = async () => {
27
+ if (stopped) return;
28
+ try {
29
+ // /api/devices/me is gate-exempt: returns 200 with the current
30
+ // device record regardless of approval state. We inspect the
31
+ // body ourselves to decide whether to dismiss the overlay.
32
+ const d = await api('GET', '/api/devices/me');
33
+ if (d && d.status === 'approved') {
34
+ pendingDevice.value = null;
35
+ // First load failed because we weren't approved yet — main.js'
36
+ // boot tried /api/config and got 401, no auto-retry. Now that
37
+ // we're through the gate, kick a one-shot load so config +
38
+ // sessions/folders/workspaces hydrate without waiting for the
39
+ // 5s tick (and config without that ever happening, since the
40
+ // periodic loop doesn't include loadConfig).
41
+ loadConfig().catch(() => {});
42
+ refreshAll().catch(() => {});
43
+ } else if (d) {
44
+ pendingDevice.value = {
45
+ pending: d.status === 'pending',
46
+ rejected: d.status === 'rejected',
47
+ deviceId: d.id,
48
+ firstSeen: d.firstSeen,
49
+ at: Date.now(),
50
+ };
51
+ }
52
+ } catch { /* network blip — try again next tick */ }
53
+ };
54
+ const id = setInterval(tick, POLL_MS);
55
+ tick();
56
+ return () => { stopped = true; clearInterval(id); };
57
+ }, [!!p]);
58
+
59
+ if (!p) return null;
60
+
61
+ const rejected = !!p.rejected;
62
+ const firstSeen = p.firstSeen ? new Date(p.firstSeen).toLocaleTimeString() : null;
63
+
64
+ return html`
65
+ <div class="offline-overlay" role="dialog" aria-modal="true" aria-live="polite">
66
+ <div class="offline-card">
67
+ <div class="offline-brand"><${BrandMark} /></div>
68
+ ${rejected ? html`
69
+ <h1 class="offline-title">Access declined</h1>
70
+ <p class="offline-copy">
71
+ The host machine rejected this device. If you think this was a
72
+ mistake, ask the operator to re-approve from the Remote page.
73
+ </p>
74
+ ` : html`
75
+ <h1 class="offline-title">Waiting for host approval</h1>
76
+ <p class="offline-copy">
77
+ The host machine got your request${firstSeen ? ` at ${firstSeen}` : ''}.
78
+ Approve this device from the Remote page over there to continue.
79
+ </p>
80
+ <p class="offline-copy" style="margin-top:6px;font-size:12px;color:var(--ink-muted)">
81
+ We'll auto-unlock the moment the host clicks Approve.
82
+ </p>
83
+ `}
84
+ </div>
85
+ </div>`;
86
+ }
@@ -1,18 +1,19 @@
1
1
  import { html } from '../html.js';
2
2
  import { signal } from '@preact/signals';
3
3
  import {
4
- activeTab, sidebarCollapsed, sidebarForcedCollapsed, configDirty, capabilities,
4
+ activeTab, sidebarCollapsed, sidebarForcedCollapsed, isMobile, configDirty, capabilities,
5
5
  sessions, folders, sessionsByFolder, foldersCollapsed, activeSessionId,
6
6
  selectTab, selectSession, toggleSidebar, toggleFolder, setSidebarWidth,
7
7
  } from '../state.js';
8
8
  import { createFolder, renameFolder, deleteFolder, reorderFolders, setSessionFolder, reorderSessions, deleteSession, resumeSession, setSessionTitle } from '../api.js';
9
+ import { isRemoteAccess } from '../backend.js';
9
10
  import { ccsmPrompt, ccsmConfirm } from '../dialog.js';
10
11
  import { setToast } from '../toast.js';
11
12
  import { fmtAgo } from '../util.js';
12
13
  import { clockTick } from '../state.js';
13
14
  import { useDragSort } from './useDragSort.js';
14
15
  import {
15
- IconLaunch, IconConfigure,
16
+ IconLaunch, IconConfigure, IconRemote,
16
17
  IconSidebarToggle, IconPencil, IconClose, IconFolder, IconFolderOpen, IconPlus, BrandMark,
17
18
  } from '../icons.js';
18
19
 
@@ -309,8 +310,13 @@ function SessionTree() {
309
310
  }
310
311
 
311
312
  export function Sidebar() {
312
- const collapsed = sidebarCollapsed.value || sidebarForcedCollapsed.value;
313
- const forced = sidebarForcedCollapsed.value;
313
+ // On phones the sidebar is rendered inside a full-screen drawer
314
+ // (App applies .is-mobile + .drawer-open classes). It should always
315
+ // appear in EXPANDED form there — full labels + sessions tree.
316
+ // Desktop/tablet keeps the original collapse behaviour.
317
+ const mobile = isMobile.value;
318
+ const collapsed = !mobile && (sidebarCollapsed.value || sidebarForcedCollapsed.value);
319
+ const forced = !mobile && sidebarForcedCollapsed.value;
314
320
 
315
321
  const onResizeStart = (ev) => {
316
322
  if (collapsed) return;
@@ -346,6 +352,9 @@ export function Sidebar() {
346
352
  <nav class="sidebar-nav compact" role="tablist" aria-label="Sections">
347
353
  <${NavItem} tab="launch" icon=${html`<${IconLaunch} />`} label="New Session" />
348
354
  <${NavItem} tab="configure" icon=${html`<${IconConfigure} />`} label="Settings" dirty=${configDirty.value} />
355
+ ${!isRemoteAccess() ? html`
356
+ <${NavItem} tab="remote" icon=${html`<${IconRemote} />`} label="Remote" />
357
+ ` : null}
349
358
  </nav>
350
359
 
351
360
  ${!collapsed ? html`<${SessionTree} />` : null}
@@ -9,7 +9,7 @@ import { FitAddon } from '@xterm/addon-fit';
9
9
  import { WebLinksAddon } from '@xterm/addon-web-links';
10
10
  import { ClipboardAddon } from '@xterm/addon-clipboard';
11
11
  import { WebglAddon } from '@xterm/addon-webgl';
12
- import { wsBase } from '../backend.js';
12
+ import { wsBase, getToken, getDeviceId } from '../backend.js';
13
13
 
14
14
  // Dark xterm theme. We give the terminal a near-black ink background to
15
15
  // match what claude code's TUI assumes (it paints its own input box +
@@ -40,9 +40,15 @@ export function TerminalView({ terminalId }) {
40
40
  useEffect(() => {
41
41
  if (!terminalId || !hostRef.current) return;
42
42
 
43
+ // Mobile viewports (≤ 640px) get a smaller default font so claude's
44
+ // UI fits ~50 cols instead of the ~26 that 13px would buy at 390px.
45
+ // Desktop stays at 13. We re-evaluate on every mount, so a viewport
46
+ // rotation that crosses the breakpoint picks up the new size on
47
+ // next mount (rare; users typically don't rotate mid-session).
48
+ const baseFontSize = window.matchMedia('(max-width: 640px)').matches ? 11 : 13;
43
49
  const term = new Terminal({
44
50
  fontFamily: '"Cascadia Mono", "Geist Mono", "JetBrains Mono", Consolas, monospace',
45
- fontSize: 13,
51
+ fontSize: baseFontSize,
46
52
  lineHeight: 1.2,
47
53
  cursorBlink: true,
48
54
  cursorStyle: 'bar',
@@ -108,7 +114,17 @@ export function TerminalView({ terminalId }) {
108
114
  requestAnimationFrame(() => { try { fit.fit(); } catch {} });
109
115
  termRef.current = term;
110
116
 
111
- const ws = new WebSocket(`${wsBase()}/ws/terminal/${encodeURIComponent(terminalId)}`);
117
+ // Browser WS API can't set Authorization headers — token + device
118
+ // ride as query string when we have them (Remote-mode access).
119
+ // Server's upgrade handler reads both when Host is non-loopback.
120
+ const tok = getToken();
121
+ const dev = getDeviceId();
122
+ const params = new URLSearchParams();
123
+ if (tok) params.set('token', tok);
124
+ if (dev) params.set('device', dev);
125
+ const qs = params.toString();
126
+ const wsUrl = `${wsBase()}/ws/terminal/${encodeURIComponent(terminalId)}${qs ? `?${qs}` : ''}`;
127
+ const ws = new WebSocket(wsUrl);
112
128
  ws.binaryType = 'arraybuffer';
113
129
  wsRef.current = ws;
114
130
 
@@ -125,6 +125,35 @@ export const IconBranch = ic('0 0 24 24', html`
125
125
  <circle cx="6" cy="18" r="3"/>
126
126
  <path d="M18 9a9 9 0 0 1-9 9"/>
127
127
  `, 18);
128
+ export const IconMoreVert = ic('0 0 24 24', html`
129
+ <circle cx="12" cy="5" r="1.6" fill="currentColor" stroke="none"/>
130
+ <circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/>
131
+ <circle cx="12" cy="19" r="1.6" fill="currentColor" stroke="none"/>
132
+ `, 16);
133
+
134
+ // Broadcast / remote — radiating arcs over a centre dot. Used on the
135
+ // Remote nav tab; reads as "this machine is broadcasting" / "remote
136
+ // access available".
137
+ export const IconRemote = ic('0 0 24 24', html`
138
+ <circle cx="12" cy="12" r="2"/>
139
+ <path d="M8.5 8.5a5 5 0 0 0 0 7"/>
140
+ <path d="M15.5 8.5a5 5 0 0 1 0 7"/>
141
+ <path d="M5.5 5.5a9 9 0 0 0 0 13"/>
142
+ <path d="M18.5 5.5a9 9 0 0 1 0 13"/>
143
+ `, 18);
144
+
145
+ // Copy — two stacked rectangles. For "copy to clipboard" affordance
146
+ // next to URLs / tokens in the Remote page.
147
+ export const IconCopy = ic('0 0 24 24', html`
148
+ <rect x="9" y="9" width="11" height="11" rx="2"/>
149
+ <path d="M5 15V6a2 2 0 0 1 2-2h9"/>
150
+ `, 13);
151
+
152
+ // Recycle / regenerate — for "regenerate token" button.
153
+ export const IconRecycle = ic('0 0 24 24', html`
154
+ <polyline points="23 4 23 10 17 10"/>
155
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
156
+ `, 14);
128
157
 
129
158
  // Brand-colored CLI marks. These use external SVG assets (full color),
130
159
  // rendered as <img> so the gradients / fills in the file are preserved.
package/public/js/main.js CHANGED
@@ -5,13 +5,34 @@
5
5
  import { render } from 'preact';
6
6
  import { effect } from '@preact/signals';
7
7
  import { html } from './html.js';
8
- import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa, sidebarForcedCollapsed, activeTab, activeSessionId, sessions, TAB_HEADINGS } from './state.js';
9
- import { httpBase } from './backend.js';
10
- import { loadConfig, refreshAll, loadSessions, loadFolders, loadWorkspaces, pollHealth } from './api.js';
8
+ import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa, sidebarForcedCollapsed, isMobile, mobileDrawerOpen, activeTab, activeSessionId, sessions, TAB_HEADINGS } from './state.js';
9
+ import { httpBase, setToken, getDeviceId, isRemoteAccess } from './backend.js';
10
+ import { api, loadConfig, refreshAll, loadSessions, loadFolders, loadWorkspaces, pollHealth, pendingDevice } from './api.js';
11
11
  import { setToast } from './toast.js';
12
12
  import { App } from './components/App.js';
13
13
  import { installGlobalKeybindings } from './keybindings.js';
14
14
 
15
+ // First thing we do on boot: if the URL carries `?token=…` it's a fresh
16
+ // share link from the Remote page on the host machine. Stash it in
17
+ // localStorage so api.js / TerminalView pick it up, then strip the query
18
+ // string from the URL via history.replaceState — keeps the secret out
19
+ // of the address bar / browser history / clipboard sharing later.
20
+ // Also ensure a device id exists in localStorage right away — getDeviceId
21
+ // is a side-effecting getter (creates + persists on first call). Calling
22
+ // it here means api.js sees a stable id from the very first fetch.
23
+ (() => {
24
+ try {
25
+ const u = new URL(location.href);
26
+ const t = u.searchParams.get('token');
27
+ if (t) {
28
+ setToken(t);
29
+ u.searchParams.delete('token');
30
+ history.replaceState(null, '', u.pathname + (u.search ? `?${u.searchParams.toString()}` : '') + u.hash);
31
+ }
32
+ getDeviceId();
33
+ } catch {}
34
+ })();
35
+
15
36
  loadPersisted();
16
37
  installGlobalKeybindings();
17
38
  // Window/tab title — reactive. In standalone PWA mode we hide our own
@@ -84,16 +105,26 @@ matchMedia('(display-mode: browser)').addEventListener('change', applyIsAppClass
84
105
  matchMedia('(display-mode: window-controls-overlay)').addEventListener('change', applyIsAppClass);
85
106
  matchMedia('(display-mode: standalone)').addEventListener('change', applyIsAppClass);
86
107
 
87
- // Force-collapse the sidebar on narrow viewports. Mirrors the responsive
88
- // CSS so JS state (toggle visibility, tree-render gating) agrees with the
89
- // rendered layout.
90
- const narrowMq = matchMedia('(max-width: 900px)');
91
- function applyNarrow() { sidebarForcedCollapsed.value = narrowMq.matches; }
92
- applyNarrow();
93
- narrowMq.addEventListener('change', applyNarrow);
108
+ // The old 640–900px "force-collapse" mode is gone narrow desktops
109
+ // keep the full sidebar, phone viewports get the FAB drawer below.
110
+ // `sidebarForcedCollapsed` is left at its default `false` so any
111
+ // remaining readers (Sidebar resize handle gate, etc.) behave like
112
+ // desktop. Removing the signal entirely would mean touching every
113
+ // consumer; leaving it inert is a smaller blast radius.
94
114
 
95
- // Counter-zoom for chrome bars (page-title-bar, session-actions). Browser
96
- // page zoom (Ctrl+wheel) scales every CSS px including our header heights;
115
+ // Phone-sized viewports get a different nav model: sidebar hidden,
116
+ // floating bottom-left button opens a full-screen drawer.
117
+ const mobileMq = matchMedia('(max-width: 640px)');
118
+ function applyMobile() {
119
+ isMobile.value = mobileMq.matches;
120
+ // Always close the drawer on a breakpoint flip so the user doesn't
121
+ // resize from desktop into mobile with a phantom open drawer.
122
+ if (mobileDrawerOpen.value) mobileDrawerOpen.value = false;
123
+ }
124
+ applyMobile();
125
+ mobileMq.addEventListener('change', applyMobile);
126
+
127
+ // Counter-zoom for the page-title-bar. Browser page zoom (Ctrl+wheel) scales every CSS px including our header heights;
97
128
  // without this, the header gets visually taller at 150%+ which the user
98
129
  // usually doesn't want. We detect zoom via outerWidth/innerWidth and write
99
130
  // 1/zoom into --anti-zoom so the CSS can `calc(40px * var(--anti-zoom))`
@@ -106,6 +137,25 @@ function syncAntiZoom() {
106
137
  syncAntiZoom();
107
138
  window.addEventListener('resize', syncAntiZoom);
108
139
 
140
+ // WCO title-bar height — read the actual OS strip height via
141
+ // navigator.windowControlsOverlay.getTitlebarAreaRect() and publish it
142
+ // as --titlebar-h. CSS env(titlebar-area-height) is the analogous value
143
+ // but Chromium occasionally lies (under-reports by a couple px on Edge),
144
+ // and we don't get a JS handle to drive other measurements from. The
145
+ // JS API is the source of truth here; the rect's height is exactly the
146
+ // strip the OS leaves us. Fires on geometrychange so window-move-across-
147
+ // monitors / DPI-flip / restore-from-maximize re-sync.
148
+ function syncTitlebarHeight() {
149
+ try {
150
+ const r = navigator.windowControlsOverlay?.getTitlebarAreaRect?.();
151
+ if (r && r.height > 0) {
152
+ document.documentElement.style.setProperty('--titlebar-h', `${r.height}px`);
153
+ }
154
+ } catch { /* unsupported · CSS falls back to env() then 32px */ }
155
+ }
156
+ syncTitlebarHeight();
157
+ navigator.windowControlsOverlay?.addEventListener?.('geometrychange', syncTitlebarHeight);
158
+
109
159
  (async () => {
110
160
  // Version-mismatch guard runs FIRST. If the user's backend has been
111
161
  // upgraded since this per-version frontend was loaded, bounce back to
@@ -114,6 +164,28 @@ window.addEventListener('resize', syncAntiZoom);
114
164
  // the build-time <meta>).
115
165
  await bootVersionGuard();
116
166
 
167
+ // On a remote browser we MUST register at /api/devices/me before any
168
+ // other /api/* call — the device gate 401s with "unknown device"
169
+ // otherwise. The /me handler accepts the token from the share URL,
170
+ // creates a pending record, and (post-approval) keeps returning the
171
+ // existing record without a token. Setting pendingDevice from the
172
+ // response wakes PendingApprovalOverlay; on approval the signal
173
+ // clears in there.
174
+ if (isRemoteAccess()) {
175
+ try {
176
+ const me = await api('GET', '/api/devices/me');
177
+ if (me && me.status !== 'approved') {
178
+ pendingDevice.value = {
179
+ pending: me.status === 'pending',
180
+ rejected: me.status === 'rejected',
181
+ deviceId: me.id,
182
+ firstSeen: me.firstSeen,
183
+ at: Date.now(),
184
+ };
185
+ }
186
+ } catch (e) { /* token bad / network blip — surfaces via other calls */ }
187
+ }
188
+
117
189
  try {
118
190
  await loadConfig();
119
191
  await refreshAll();
@@ -144,7 +216,17 @@ window.addEventListener('resize', syncAntiZoom);
144
216
  // 10s cadence is short enough that any tab open for one full cycle gets
145
217
  // caught by the post-close decision in server.js; long enough not to be
146
218
  // chatty.
147
- const ping = () => fetch(httpBase() + '/api/heartbeat', { method: 'POST', keepalive: true }).catch(() => {});
219
+ const ping = () => {
220
+ const headers = {};
221
+ // Heartbeat doesn't go through api.js' wrapper but still needs the
222
+ // bearer token + device id when called via tunnel (the middleware
223
+ // blocks it otherwise and the server thinks the session went idle).
224
+ const t = (typeof localStorage !== 'undefined') ? localStorage.getItem('ccsm.token') : null;
225
+ if (t) headers['Authorization'] = `Bearer ${t}`;
226
+ const d = getDeviceId();
227
+ if (d) headers['X-Device-Id'] = d;
228
+ return fetch(httpBase() + '/api/heartbeat', { method: 'POST', headers, keepalive: true }).catch(() => {});
229
+ };
148
230
  ping();
149
231
  setInterval(ping, 10_000);
150
232
  document.addEventListener('visibilitychange', () => { if (!document.hidden) ping(); });