@bakapiano/ccsm 0.22.2 → 0.22.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.
Files changed (60) hide show
  1. package/CLAUDE.md +538 -538
  2. package/README.md +189 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +139 -139
  5. package/lib/codexSeed.js +183 -183
  6. package/lib/config.js +274 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/localCliSessions.js +519 -519
  10. package/lib/persistedSessions.js +129 -129
  11. package/lib/tunnel.js +621 -621
  12. package/lib/webTerminal.js +233 -231
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +176 -176
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +592 -592
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2725 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +371 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +187 -15
  42. package/public/js/components/TerminalResizeDebouncer.js +126 -0
  43. package/public/js/components/XtermTerminal.js +148 -14
  44. package/public/js/components/useDragSort.js +67 -67
  45. package/public/js/dialog.js +67 -67
  46. package/public/js/icons.js +212 -212
  47. package/public/js/main.js +296 -296
  48. package/public/js/pages/AboutPage.js +90 -90
  49. package/public/js/pages/ConfigurePage.js +713 -713
  50. package/public/js/pages/LaunchPage.js +421 -421
  51. package/public/js/pages/RemotePage.js +743 -743
  52. package/public/js/pages/SessionsPage.js +100 -100
  53. package/public/js/state.js +335 -335
  54. package/public/manifest.webmanifest +25 -0
  55. package/public/setup/index.html +567 -0
  56. package/scripts/dev.js +149 -149
  57. package/scripts/install.js +153 -153
  58. package/scripts/restart-helper.js +96 -96
  59. package/scripts/upgrade-helper.js +687 -687
  60. package/server.js +1807 -1807
package/public/js/main.js CHANGED
@@ -1,296 +1,296 @@
1
- // Entry. Loads persisted ui state → boots data → mounts App → spins up
2
- // the 5s auto-refresh + 1s clock tick. No imperative DOM access outside
3
- // the mount root.
4
-
5
- import { render } from 'preact';
6
- import { effect } from '@preact/signals';
7
- import { html } from './html.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
- import { setToast } from './toast.js';
12
- import { App } from './components/App.js';
13
- import { installGlobalKeybindings } from './keybindings.js';
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
-
36
- loadPersisted();
37
- installGlobalKeybindings();
38
- // Window/tab title — reactive. In standalone PWA mode we hide our own
39
- // .page-title-bar and the browser-drawn OS title bar takes its place,
40
- // so document.title is what the user actually sees as the header. It
41
- // mirrors what would have been in our hidden header: session title +
42
- // cwd on the Sessions tab, the page heading elsewhere.
43
- // MutationObserver guards against Chromium standalone builds that
44
- // occasionally try to inject the URL into the title bar.
45
- let desiredTitle = 'CCSM';
46
- function lockTitle() { if (document.title !== desiredTitle) document.title = desiredTitle; }
47
- function computeTitle() {
48
- const tab = activeTab.value;
49
- if (tab === 'sessions') {
50
- const id = activeSessionId.value;
51
- const s = id ? sessions.value.find((x) => x.id === id) : null;
52
- if (s) {
53
- const name = s.title || s.workspace || s.id.slice(0, 12);
54
- return `${name} · ${s.cwd} · CCSM`;
55
- }
56
- return 'Sessions · CCSM';
57
- }
58
- return `${TAB_HEADINGS[tab]?.title || 'CCSM'} · CCSM`;
59
- }
60
- effect(() => { desiredTitle = computeTitle(); lockTitle(); });
61
- new MutationObserver(lockTitle).observe(
62
- document.querySelector('title') || document.head,
63
- { childList: true, subtree: true, characterData: true }
64
- );
65
- render(html`<${App} />`, document.getElementById('app'));
66
-
67
- // PWA install affordance — Chromium fires `beforeinstallprompt` when the
68
- // manifest meets install criteria (served over localhost / https, has icon,
69
- // not already installed). We stash the event so the About page can offer
70
- // a one-click install button that triggers it.
71
- window.addEventListener('beforeinstallprompt', (ev) => {
72
- ev.preventDefault();
73
- installPrompt.value = ev;
74
- });
75
- window.addEventListener('appinstalled', () => {
76
- installPrompt.value = null;
77
- isInstalledPwa.value = true;
78
- });
79
- // On boot, detect if we're already running as an installed PWA window
80
- // (display-mode standalone covers both plain PWA + WCO). When true, the
81
- // "install" affordance hides itself.
82
- const mq = matchMedia('(display-mode: standalone), (display-mode: window-controls-overlay)');
83
- isInstalledPwa.value = mq.matches;
84
- mq.addEventListener('change', () => { isInstalledPwa.value = mq.matches; });
85
-
86
- // "is-app" body class · everything that isn't a regular browser tab
87
- // (display-mode: browser) gets it. Used by wco.css to gate user-select
88
- // on drag regions so chromeless --app= windows can be dragged by
89
- // clicking the page title, while normal tabs still allow text select.
90
- //
91
- // "is-wco" is the stricter case: window-controls-overlay mode where the
92
- // browser hides its title bar entirely and only floats OS controls in
93
- // the top-right. In that mode our .page-title-bar IS the title bar and
94
- // needs the 34px height + padding-right reservation. In plain standalone
95
- // PWA (browser still paints its own title bar above our content), we
96
- // don't need any of that — page-title-bar can behave like a regular tab.
97
- function applyIsAppClass() {
98
- const isApp = !matchMedia('(display-mode: browser)').matches;
99
- const isWco = matchMedia('(display-mode: window-controls-overlay)').matches;
100
- document.body.classList.toggle('is-app', isApp);
101
- document.body.classList.toggle('is-wco', isWco);
102
- }
103
- applyIsAppClass();
104
- matchMedia('(display-mode: browser)').addEventListener('change', applyIsAppClass);
105
- matchMedia('(display-mode: window-controls-overlay)').addEventListener('change', applyIsAppClass);
106
- matchMedia('(display-mode: standalone)').addEventListener('change', applyIsAppClass);
107
-
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.
114
-
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;
128
- // without this, the header gets visually taller at 150%+ which the user
129
- // usually doesn't want. We detect zoom via outerWidth/innerWidth and write
130
- // 1/zoom into --anti-zoom so the CSS can `calc(40px * var(--anti-zoom))`
131
- // each bar back to a constant on-screen height.
132
- function syncAntiZoom() {
133
- const z = window.outerWidth / window.innerWidth || 1;
134
- const inv = Math.max(0.4, Math.min(1, 1 / z)); // clamp: never grow > 100%
135
- document.documentElement.style.setProperty('--anti-zoom', String(inv));
136
- }
137
- syncAntiZoom();
138
- window.addEventListener('resize', syncAntiZoom);
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
-
159
- // Mobile soft-keyboard height. The layout viewport (100vh) does NOT shrink
160
- // when the on-screen keyboard slides up — only `visualViewport` does — so a
161
- // full-height terminal keeps its bottom rows hidden behind the keyboard. We
162
- // publish the visible height as --app-vh (used by .app.is-mobile in
163
- // responsive.css to shrink the whole app to the area above the keyboard)
164
- // and flag body.kb-open when the keyboard is up (so the terminal can reserve
165
- // room for the floating key bar). cap at a 120px delta so a browser
166
- // URL-bar collapse doesn't read as a keyboard.
167
- function syncViewportHeight() {
168
- const vv = window.visualViewport;
169
- if (!vv) return;
170
- document.documentElement.style.setProperty('--app-vh', `${Math.round(vv.height)}px`);
171
- const kbUp = (window.innerHeight - vv.height - vv.offsetTop) > 120;
172
- document.body.classList.toggle('kb-open', kbUp);
173
- }
174
- syncViewportHeight();
175
- window.visualViewport?.addEventListener?.('resize', syncViewportHeight);
176
- window.visualViewport?.addEventListener?.('scroll', syncViewportHeight);
177
- window.addEventListener('resize', syncViewportHeight);
178
-
179
- (async () => {
180
- // Version-mismatch guard runs FIRST. If the user's backend has been
181
- // upgraded since this per-version frontend was loaded, bounce back to
182
- // the router immediately — no point loading config from a server that
183
- // speaks a different API revision. Runs in dev too (it no-ops without
184
- // the build-time <meta>).
185
- await bootVersionGuard();
186
-
187
- // On a remote browser we MUST register at /api/devices/me before any
188
- // other /api/* call — the device gate 401s with "unknown device"
189
- // otherwise. The /me handler accepts the token from the share URL,
190
- // creates a pending record, and (post-approval) keeps returning the
191
- // existing record without a token. Setting pendingDevice from the
192
- // response wakes PendingApprovalOverlay; on approval the signal
193
- // clears in there.
194
- if (isRemoteAccess()) {
195
- try {
196
- const me = await api('GET', '/api/devices/me');
197
- if (me && me.status !== 'approved') {
198
- pendingDevice.value = {
199
- pending: me.status === 'pending',
200
- rejected: me.status === 'rejected',
201
- deviceId: me.id,
202
- firstSeen: me.firstSeen,
203
- at: Date.now(),
204
- };
205
- }
206
- } catch (e) { /* token bad / network blip — surfaces via other calls */ }
207
- }
208
-
209
- try {
210
- await loadConfig();
211
- await refreshAll();
212
- pollHealth();
213
- } catch (e) {
214
- setToast('initial load failed · ' + e.message, 'error');
215
- }
216
-
217
- // 5s data refresh + clock tick (same cadence so fmtAgo "Ns ago" relative
218
- // labels naturally track the data refresh; bumping clockTick more
219
- // frequently would just cause needless re-renders since fmtAgo's
220
- // resolution is coarse — 5s buckets under a minute, then m/h/d).
221
- // loadWorkspaces is included because the workspace "in use" flag is
222
- // derived from live session cwds server-side — without it, sessions
223
- // move in/out of a workspace silently and the grid stays stale.
224
- // Skipped while a remote tab is sitting in the pending-approval
225
- // overlay — every call would 403, fill the console with red, and the
226
- // user can't see anything anyway. PendingApprovalOverlay handles its
227
- // own re-hydrate the moment we get approved.
228
- setInterval(async () => {
229
- if (pendingDevice.value) {
230
- // Skip the data fetches (every one would 403) but still poll
231
- // health so the OfflineBanner can show if the host goes down
232
- // while we're sitting on the approval screen.
233
- pollHealth();
234
- clockTick.value = Date.now();
235
- return;
236
- }
237
- try {
238
- await Promise.all([loadSessions(), loadFolders(), loadWorkspaces()]);
239
- lastRefreshAt.value = Date.now();
240
- } catch { /* swallow — next tick retries */ }
241
- pollHealth();
242
- clockTick.value = Date.now();
243
- }, 5000);
244
-
245
- // Heartbeat · the server uses this to (a) decide whether to shut down
246
- // when its own spawned browser closes (multi-client check), and (b) as
247
- // a 90s watchdog backup if the browser-exit signal is missed entirely.
248
- // 10s cadence is short enough that any tab open for one full cycle gets
249
- // caught by the post-close decision in server.js; long enough not to be
250
- // chatty.
251
- const ping = () => {
252
- // While we're stuck on the pending-approval overlay, /api/heartbeat
253
- // would 403 every 10s. Pointless noise — the host's watchdog is
254
- // gated on real user activity anyway. Resumes automatically once
255
- // pendingDevice clears.
256
- if (pendingDevice.value) return Promise.resolve();
257
- const headers = {};
258
- // Heartbeat doesn't go through api.js' wrapper but still needs the
259
- // bearer token + device id when called via tunnel (the middleware
260
- // blocks it otherwise and the server thinks the session went idle).
261
- const t = (typeof localStorage !== 'undefined') ? localStorage.getItem('ccsm.token') : null;
262
- if (t) headers['Authorization'] = `Bearer ${t}`;
263
- const d = getDeviceId();
264
- if (d) headers['X-Device-Id'] = d;
265
- return fetch(httpBase() + '/api/heartbeat', { method: 'POST', headers, keepalive: true }).catch(() => {});
266
- };
267
- ping();
268
- setInterval(ping, 10_000);
269
- document.addEventListener('visibilitychange', () => { if (!document.hidden) ping(); });
270
- })();
271
-
272
- // ─── version routing guard ───────────────────────────────────────────
273
- // Each deployed frontend is pinned to one backend version. The GH-Pages
274
- // workflow bakes the version into <meta name="ccsm-frontend-version">
275
- // so we can detect "backend has been upgraded since this frontend was
276
- // loaded" and bounce back through the router at /ccsm/ for a fresh
277
- // match. In dev (no meta tag, same-origin served-by-backend), the check
278
- // no-ops — we're always running the frontend that ships with this
279
- // backend by definition.
280
- async function bootVersionGuard() {
281
- const meta = document.querySelector('meta[name="ccsm-frontend-version"]');
282
- if (!meta) return; // dev mode
283
- const myVer = meta.getAttribute('content');
284
- if (!myVer) return;
285
- let backendVer = null;
286
- try {
287
- const r = await fetch(httpBase() + '/api/health', { cache: 'no-store' });
288
- if (!r.ok) return;
289
- backendVer = (await r.json()).version;
290
- } catch { return; } // offline → OfflineBanner takes over
291
- if (!backendVer || backendVer === myVer) return;
292
- // Mismatch. Bounce up one level to the router. The router will
293
- // probe /api/health again and redirect to ./<backendVer>/.
294
- console.warn(`[ccsm] frontend ${myVer} ≠ backend ${backendVer} — re-routing`);
295
- location.replace('../');
296
- }
1
+ // Entry. Loads persisted ui state → boots data → mounts App → spins up
2
+ // the 5s auto-refresh + 1s clock tick. No imperative DOM access outside
3
+ // the mount root.
4
+
5
+ import { render } from 'preact';
6
+ import { effect } from '@preact/signals';
7
+ import { html } from './html.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
+ import { setToast } from './toast.js';
12
+ import { App } from './components/App.js';
13
+ import { installGlobalKeybindings } from './keybindings.js';
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
+
36
+ loadPersisted();
37
+ installGlobalKeybindings();
38
+ // Window/tab title — reactive. In standalone PWA mode we hide our own
39
+ // .page-title-bar and the browser-drawn OS title bar takes its place,
40
+ // so document.title is what the user actually sees as the header. It
41
+ // mirrors what would have been in our hidden header: session title +
42
+ // cwd on the Sessions tab, the page heading elsewhere.
43
+ // MutationObserver guards against Chromium standalone builds that
44
+ // occasionally try to inject the URL into the title bar.
45
+ let desiredTitle = 'CCSM';
46
+ function lockTitle() { if (document.title !== desiredTitle) document.title = desiredTitle; }
47
+ function computeTitle() {
48
+ const tab = activeTab.value;
49
+ if (tab === 'sessions') {
50
+ const id = activeSessionId.value;
51
+ const s = id ? sessions.value.find((x) => x.id === id) : null;
52
+ if (s) {
53
+ const name = s.title || s.workspace || s.id.slice(0, 12);
54
+ return `${name} · ${s.cwd} · CCSM`;
55
+ }
56
+ return 'Sessions · CCSM';
57
+ }
58
+ return `${TAB_HEADINGS[tab]?.title || 'CCSM'} · CCSM`;
59
+ }
60
+ effect(() => { desiredTitle = computeTitle(); lockTitle(); });
61
+ new MutationObserver(lockTitle).observe(
62
+ document.querySelector('title') || document.head,
63
+ { childList: true, subtree: true, characterData: true }
64
+ );
65
+ render(html`<${App} />`, document.getElementById('app'));
66
+
67
+ // PWA install affordance — Chromium fires `beforeinstallprompt` when the
68
+ // manifest meets install criteria (served over localhost / https, has icon,
69
+ // not already installed). We stash the event so the About page can offer
70
+ // a one-click install button that triggers it.
71
+ window.addEventListener('beforeinstallprompt', (ev) => {
72
+ ev.preventDefault();
73
+ installPrompt.value = ev;
74
+ });
75
+ window.addEventListener('appinstalled', () => {
76
+ installPrompt.value = null;
77
+ isInstalledPwa.value = true;
78
+ });
79
+ // On boot, detect if we're already running as an installed PWA window
80
+ // (display-mode standalone covers both plain PWA + WCO). When true, the
81
+ // "install" affordance hides itself.
82
+ const mq = matchMedia('(display-mode: standalone), (display-mode: window-controls-overlay)');
83
+ isInstalledPwa.value = mq.matches;
84
+ mq.addEventListener('change', () => { isInstalledPwa.value = mq.matches; });
85
+
86
+ // "is-app" body class · everything that isn't a regular browser tab
87
+ // (display-mode: browser) gets it. Used by wco.css to gate user-select
88
+ // on drag regions so chromeless --app= windows can be dragged by
89
+ // clicking the page title, while normal tabs still allow text select.
90
+ //
91
+ // "is-wco" is the stricter case: window-controls-overlay mode where the
92
+ // browser hides its title bar entirely and only floats OS controls in
93
+ // the top-right. In that mode our .page-title-bar IS the title bar and
94
+ // needs the 34px height + padding-right reservation. In plain standalone
95
+ // PWA (browser still paints its own title bar above our content), we
96
+ // don't need any of that — page-title-bar can behave like a regular tab.
97
+ function applyIsAppClass() {
98
+ const isApp = !matchMedia('(display-mode: browser)').matches;
99
+ const isWco = matchMedia('(display-mode: window-controls-overlay)').matches;
100
+ document.body.classList.toggle('is-app', isApp);
101
+ document.body.classList.toggle('is-wco', isWco);
102
+ }
103
+ applyIsAppClass();
104
+ matchMedia('(display-mode: browser)').addEventListener('change', applyIsAppClass);
105
+ matchMedia('(display-mode: window-controls-overlay)').addEventListener('change', applyIsAppClass);
106
+ matchMedia('(display-mode: standalone)').addEventListener('change', applyIsAppClass);
107
+
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.
114
+
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;
128
+ // without this, the header gets visually taller at 150%+ which the user
129
+ // usually doesn't want. We detect zoom via outerWidth/innerWidth and write
130
+ // 1/zoom into --anti-zoom so the CSS can `calc(40px * var(--anti-zoom))`
131
+ // each bar back to a constant on-screen height.
132
+ function syncAntiZoom() {
133
+ const z = window.outerWidth / window.innerWidth || 1;
134
+ const inv = Math.max(0.4, Math.min(1, 1 / z)); // clamp: never grow > 100%
135
+ document.documentElement.style.setProperty('--anti-zoom', String(inv));
136
+ }
137
+ syncAntiZoom();
138
+ window.addEventListener('resize', syncAntiZoom);
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
+
159
+ // Mobile soft-keyboard height. The layout viewport (100vh) does NOT shrink
160
+ // when the on-screen keyboard slides up — only `visualViewport` does — so a
161
+ // full-height terminal keeps its bottom rows hidden behind the keyboard. We
162
+ // publish the visible height as --app-vh (used by .app.is-mobile in
163
+ // responsive.css to shrink the whole app to the area above the keyboard)
164
+ // and flag body.kb-open when the keyboard is up (so the terminal can reserve
165
+ // room for the floating key bar). cap at a 120px delta so a browser
166
+ // URL-bar collapse doesn't read as a keyboard.
167
+ function syncViewportHeight() {
168
+ const vv = window.visualViewport;
169
+ if (!vv) return;
170
+ document.documentElement.style.setProperty('--app-vh', `${Math.round(vv.height)}px`);
171
+ const kbUp = (window.innerHeight - vv.height - vv.offsetTop) > 120;
172
+ document.body.classList.toggle('kb-open', kbUp);
173
+ }
174
+ syncViewportHeight();
175
+ window.visualViewport?.addEventListener?.('resize', syncViewportHeight);
176
+ window.visualViewport?.addEventListener?.('scroll', syncViewportHeight);
177
+ window.addEventListener('resize', syncViewportHeight);
178
+
179
+ (async () => {
180
+ // Version-mismatch guard runs FIRST. If the user's backend has been
181
+ // upgraded since this per-version frontend was loaded, bounce back to
182
+ // the router immediately — no point loading config from a server that
183
+ // speaks a different API revision. Runs in dev too (it no-ops without
184
+ // the build-time <meta>).
185
+ await bootVersionGuard();
186
+
187
+ // On a remote browser we MUST register at /api/devices/me before any
188
+ // other /api/* call — the device gate 401s with "unknown device"
189
+ // otherwise. The /me handler accepts the token from the share URL,
190
+ // creates a pending record, and (post-approval) keeps returning the
191
+ // existing record without a token. Setting pendingDevice from the
192
+ // response wakes PendingApprovalOverlay; on approval the signal
193
+ // clears in there.
194
+ if (isRemoteAccess()) {
195
+ try {
196
+ const me = await api('GET', '/api/devices/me');
197
+ if (me && me.status !== 'approved') {
198
+ pendingDevice.value = {
199
+ pending: me.status === 'pending',
200
+ rejected: me.status === 'rejected',
201
+ deviceId: me.id,
202
+ firstSeen: me.firstSeen,
203
+ at: Date.now(),
204
+ };
205
+ }
206
+ } catch (e) { /* token bad / network blip — surfaces via other calls */ }
207
+ }
208
+
209
+ try {
210
+ await loadConfig();
211
+ await refreshAll();
212
+ pollHealth();
213
+ } catch (e) {
214
+ setToast('initial load failed · ' + e.message, 'error');
215
+ }
216
+
217
+ // 5s data refresh + clock tick (same cadence so fmtAgo "Ns ago" relative
218
+ // labels naturally track the data refresh; bumping clockTick more
219
+ // frequently would just cause needless re-renders since fmtAgo's
220
+ // resolution is coarse — 5s buckets under a minute, then m/h/d).
221
+ // loadWorkspaces is included because the workspace "in use" flag is
222
+ // derived from live session cwds server-side — without it, sessions
223
+ // move in/out of a workspace silently and the grid stays stale.
224
+ // Skipped while a remote tab is sitting in the pending-approval
225
+ // overlay — every call would 403, fill the console with red, and the
226
+ // user can't see anything anyway. PendingApprovalOverlay handles its
227
+ // own re-hydrate the moment we get approved.
228
+ setInterval(async () => {
229
+ if (pendingDevice.value) {
230
+ // Skip the data fetches (every one would 403) but still poll
231
+ // health so the OfflineBanner can show if the host goes down
232
+ // while we're sitting on the approval screen.
233
+ pollHealth();
234
+ clockTick.value = Date.now();
235
+ return;
236
+ }
237
+ try {
238
+ await Promise.all([loadSessions(), loadFolders(), loadWorkspaces()]);
239
+ lastRefreshAt.value = Date.now();
240
+ } catch { /* swallow — next tick retries */ }
241
+ pollHealth();
242
+ clockTick.value = Date.now();
243
+ }, 5000);
244
+
245
+ // Heartbeat · the server uses this to (a) decide whether to shut down
246
+ // when its own spawned browser closes (multi-client check), and (b) as
247
+ // a 90s watchdog backup if the browser-exit signal is missed entirely.
248
+ // 10s cadence is short enough that any tab open for one full cycle gets
249
+ // caught by the post-close decision in server.js; long enough not to be
250
+ // chatty.
251
+ const ping = () => {
252
+ // While we're stuck on the pending-approval overlay, /api/heartbeat
253
+ // would 403 every 10s. Pointless noise — the host's watchdog is
254
+ // gated on real user activity anyway. Resumes automatically once
255
+ // pendingDevice clears.
256
+ if (pendingDevice.value) return Promise.resolve();
257
+ const headers = {};
258
+ // Heartbeat doesn't go through api.js' wrapper but still needs the
259
+ // bearer token + device id when called via tunnel (the middleware
260
+ // blocks it otherwise and the server thinks the session went idle).
261
+ const t = (typeof localStorage !== 'undefined') ? localStorage.getItem('ccsm.token') : null;
262
+ if (t) headers['Authorization'] = `Bearer ${t}`;
263
+ const d = getDeviceId();
264
+ if (d) headers['X-Device-Id'] = d;
265
+ return fetch(httpBase() + '/api/heartbeat', { method: 'POST', headers, keepalive: true }).catch(() => {});
266
+ };
267
+ ping();
268
+ setInterval(ping, 10_000);
269
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) ping(); });
270
+ })();
271
+
272
+ // ─── version routing guard ───────────────────────────────────────────
273
+ // Each deployed frontend is pinned to one backend version. The GH-Pages
274
+ // workflow bakes the version into <meta name="ccsm-frontend-version">
275
+ // so we can detect "backend has been upgraded since this frontend was
276
+ // loaded" and bounce back through the router at /ccsm/ for a fresh
277
+ // match. In dev (no meta tag, same-origin served-by-backend), the check
278
+ // no-ops — we're always running the frontend that ships with this
279
+ // backend by definition.
280
+ async function bootVersionGuard() {
281
+ const meta = document.querySelector('meta[name="ccsm-frontend-version"]');
282
+ if (!meta) return; // dev mode
283
+ const myVer = meta.getAttribute('content');
284
+ if (!myVer) return;
285
+ let backendVer = null;
286
+ try {
287
+ const r = await fetch(httpBase() + '/api/health', { cache: 'no-store' });
288
+ if (!r.ok) return;
289
+ backendVer = (await r.json()).version;
290
+ } catch { return; } // offline → OfflineBanner takes over
291
+ if (!backendVer || backendVer === myVer) return;
292
+ // Mismatch. Bounce up one level to the router. The router will
293
+ // probe /api/health again and redirect to ./<backendVer>/.
294
+ console.warn(`[ccsm] frontend ${myVer} ≠ backend ${backendVer} — re-routing`);
295
+ location.replace('../');
296
+ }