@bakapiano/ccsm 0.10.3 → 0.12.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.
Files changed (51) hide show
  1. package/CLAUDE.md +475 -475
  2. package/README.md +190 -190
  3. package/bin/ccsm.js +194 -194
  4. package/lib/atomicJson.js +48 -0
  5. package/lib/cliSessionWatcher.js +249 -249
  6. package/lib/config.js +188 -185
  7. package/lib/folders.js +105 -96
  8. package/lib/jsonStore.js +15 -10
  9. package/lib/localCliSessions.js +489 -177
  10. package/lib/persistedSessions.js +142 -134
  11. package/lib/webTerminal.js +208 -208
  12. package/lib/workspace.js +230 -255
  13. package/package.json +57 -57
  14. package/public/css/base.css +99 -99
  15. package/public/css/cards.css +183 -183
  16. package/public/css/feedback.css +303 -303
  17. package/public/css/forms.css +405 -405
  18. package/public/css/layout.css +160 -160
  19. package/public/css/modal.css +190 -183
  20. package/public/css/responsive.css +10 -10
  21. package/public/css/sidebar.css +608 -601
  22. package/public/css/terminals.css +294 -294
  23. package/public/css/tokens.css +81 -79
  24. package/public/css/wco.css +98 -98
  25. package/public/css/widgets.css +1596 -1375
  26. package/public/index.html +105 -103
  27. package/public/js/api.js +272 -260
  28. package/public/js/components/AdoptModal.js +343 -171
  29. package/public/js/components/App.js +35 -35
  30. package/public/js/components/DirectoryPicker.js +203 -203
  31. package/public/js/components/EntityFormModal.js +105 -105
  32. package/public/js/components/Modal.js +51 -51
  33. package/public/js/components/OfflineBanner.js +93 -93
  34. package/public/js/components/PageTitleBar.js +13 -13
  35. package/public/js/components/Picker.js +179 -179
  36. package/public/js/components/Popover.js +55 -55
  37. package/public/js/components/Sidebar.js +341 -270
  38. package/public/js/components/TerminalView.js +298 -298
  39. package/public/js/components/useDragSort.js +67 -67
  40. package/public/js/dialog.js +67 -67
  41. package/public/js/icons.js +177 -177
  42. package/public/js/main.js +132 -140
  43. package/public/js/pages/AboutPage.js +165 -165
  44. package/public/js/pages/ConfigurePage.js +475 -487
  45. package/public/js/pages/LaunchPage.js +369 -369
  46. package/public/js/pages/SessionsPage.js +97 -97
  47. package/public/js/state.js +231 -231
  48. package/public/manifest.webmanifest +15 -15
  49. package/scripts/dev.js +59 -0
  50. package/scripts/install.js +137 -137
  51. package/server.js +1147 -1117
package/public/js/main.js CHANGED
@@ -1,140 +1,132 @@
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, serverHealth } from './state.js';
9
- import { httpBase } from './backend.js';
10
- import { loadConfig, refreshAll, loadSessions, loadFolders, loadWorkspaces, pollHealth } from './api.js';
11
- import { setToast } from './toast.js';
12
- import { App } from './components/App.js';
13
-
14
- loadPersisted();
15
- // Window/tab title tracks the live backend version "CCSM v0.10.1" once
16
- // /api/health responds, "CCSM" before then. A MutationObserver guards
17
- // against Chromium standalone builds that occasionally try to inject the
18
- // URL into the title bar; it accepts any "CCSM..." string we set and
19
- // resets anything else.
20
- function desiredTitle() {
21
- const v = serverHealth.value.version;
22
- return v ? `CCSM v${v}` : 'CCSM';
23
- }
24
- let expected = desiredTitle();
25
- function lockTitle() { if (document.title !== expected) document.title = expected; }
26
- lockTitle();
27
- new MutationObserver(lockTitle).observe(
28
- document.querySelector('title') || document.head,
29
- { childList: true, subtree: true, characterData: true }
30
- );
31
- effect(() => { expected = desiredTitle(); lockTitle(); });
32
- render(html`<${App} />`, document.getElementById('app'));
33
-
34
- // PWA install affordance — Chromium fires `beforeinstallprompt` when the
35
- // manifest meets install criteria (served over localhost / https, has icon,
36
- // not already installed). We stash the event so the About page can offer
37
- // a one-click install button that triggers it.
38
- window.addEventListener('beforeinstallprompt', (ev) => {
39
- ev.preventDefault();
40
- installPrompt.value = ev;
41
- });
42
- window.addEventListener('appinstalled', () => {
43
- installPrompt.value = null;
44
- isInstalledPwa.value = true;
45
- });
46
- // On boot, detect if we're already running as an installed PWA window
47
- // (display-mode standalone covers both plain PWA + WCO). When true, the
48
- // "install" affordance hides itself.
49
- const mq = matchMedia('(display-mode: standalone), (display-mode: window-controls-overlay)');
50
- isInstalledPwa.value = mq.matches;
51
- mq.addEventListener('change', () => { isInstalledPwa.value = mq.matches; });
52
-
53
- // "is-app" body class · everything that isn't a regular browser tab
54
- // (display-mode: browser) gets it. Used by wco.css to gate user-select
55
- // on drag regions so chromeless --app= windows can be dragged by
56
- // clicking the page title, while normal tabs still allow text select.
57
- function applyIsAppClass() {
58
- const isApp = !matchMedia('(display-mode: browser)').matches;
59
- document.body.classList.toggle('is-app', isApp);
60
- }
61
- applyIsAppClass();
62
- matchMedia('(display-mode: browser)').addEventListener('change', applyIsAppClass);
63
-
64
- // Force-collapse the sidebar on narrow viewports. Mirrors the responsive
65
- // CSS so JS state (toggle visibility, tree-render gating) agrees with the
66
- // rendered layout.
67
- const narrowMq = matchMedia('(max-width: 900px)');
68
- function applyNarrow() { sidebarForcedCollapsed.value = narrowMq.matches; }
69
- applyNarrow();
70
- narrowMq.addEventListener('change', applyNarrow);
71
-
72
- (async () => {
73
- // Version-mismatch guard runs FIRST. If the user's backend has been
74
- // upgraded since this per-version frontend was loaded, bounce back to
75
- // the router immediately — no point loading config from a server that
76
- // speaks a different API revision. Runs in dev too (it no-ops without
77
- // the build-time <meta>).
78
- await bootVersionGuard();
79
-
80
- try {
81
- await loadConfig();
82
- await refreshAll();
83
- pollHealth();
84
- } catch (e) {
85
- setToast('initial load failed · ' + e.message, 'error');
86
- }
87
-
88
- // 5s data refresh + clock tick (same cadence so fmtAgo "Ns ago" relative
89
- // labels naturally track the data refresh; bumping clockTick more
90
- // frequently would just cause needless re-renders since fmtAgo's
91
- // resolution is coarse5s buckets under a minute, then m/h/d).
92
- // loadWorkspaces is included because the workspace "in use" flag is
93
- // derived from live session cwds server-side — without it, sessions
94
- // move in/out of a workspace silently and the grid stays stale.
95
- setInterval(async () => {
96
- try {
97
- await Promise.all([loadSessions(), loadFolders(), loadWorkspaces()]);
98
- lastRefreshAt.value = Date.now();
99
- } catch { /* swallow next tick retries */ }
100
- pollHealth();
101
- clockTick.value = Date.now();
102
- }, 5000);
103
-
104
- // Heartbeat · the server uses this to (a) decide whether to shut down
105
- // when its own spawned browser closes (multi-client check), and (b) as
106
- // a 90s watchdog backup if the browser-exit signal is missed entirely.
107
- // 10s cadence is short enough that any tab open for one full cycle gets
108
- // caught by the post-close decision in server.js; long enough not to be
109
- // chatty.
110
- const ping = () => fetch(httpBase() + '/api/heartbeat', { method: 'POST', keepalive: true }).catch(() => {});
111
- ping();
112
- setInterval(ping, 10_000);
113
- document.addEventListener('visibilitychange', () => { if (!document.hidden) ping(); });
114
- })();
115
-
116
- // ─── version routing guard ───────────────────────────────────────────
117
- // Each deployed frontend is pinned to one backend version. The GH-Pages
118
- // workflow bakes the version into <meta name="ccsm-frontend-version">
119
- // so we can detect "backend has been upgraded since this frontend was
120
- // loaded" and bounce back through the router at /ccsm/ for a fresh
121
- // match. In dev (no meta tag, same-origin served-by-backend), the check
122
- // no-ops — we're always running the frontend that ships with this
123
- // backend by definition.
124
- async function bootVersionGuard() {
125
- const meta = document.querySelector('meta[name="ccsm-frontend-version"]');
126
- if (!meta) return; // dev mode
127
- const myVer = meta.getAttribute('content');
128
- if (!myVer) return;
129
- let backendVer = null;
130
- try {
131
- const r = await fetch(httpBase() + '/api/health', { cache: 'no-store' });
132
- if (!r.ok) return;
133
- backendVer = (await r.json()).version;
134
- } catch { return; } // offline → OfflineBanner takes over
135
- if (!backendVer || backendVer === myVer) return;
136
- // Mismatch. Bounce up one level to the router. The router will
137
- // probe /api/health again and redirect to ./<backendVer>/.
138
- console.warn(`[ccsm] frontend ${myVer} ≠ backend ${backendVer} — re-routing`);
139
- location.replace('../');
140
- }
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 { html } from './html.js';
7
+ import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa, sidebarForcedCollapsed } from './state.js';
8
+ import { httpBase } from './backend.js';
9
+ import { loadConfig, refreshAll, loadSessions, loadFolders, loadWorkspaces, pollHealth } from './api.js';
10
+ import { setToast } from './toast.js';
11
+ import { App } from './components/App.js';
12
+
13
+ loadPersisted();
14
+ // Window/tab title pinned to "CCSM". A MutationObserver guards against
15
+ // Chromium standalone builds that occasionally try to inject the URL
16
+ // into the title bar.
17
+ const expected = 'CCSM';
18
+ function lockTitle() { if (document.title !== expected) document.title = expected; }
19
+ lockTitle();
20
+ new MutationObserver(lockTitle).observe(
21
+ document.querySelector('title') || document.head,
22
+ { childList: true, subtree: true, characterData: true }
23
+ );
24
+ render(html`<${App} />`, document.getElementById('app'));
25
+
26
+ // PWA install affordance — Chromium fires `beforeinstallprompt` when the
27
+ // manifest meets install criteria (served over localhost / https, has icon,
28
+ // not already installed). We stash the event so the About page can offer
29
+ // a one-click install button that triggers it.
30
+ window.addEventListener('beforeinstallprompt', (ev) => {
31
+ ev.preventDefault();
32
+ installPrompt.value = ev;
33
+ });
34
+ window.addEventListener('appinstalled', () => {
35
+ installPrompt.value = null;
36
+ isInstalledPwa.value = true;
37
+ });
38
+ // On boot, detect if we're already running as an installed PWA window
39
+ // (display-mode standalone covers both plain PWA + WCO). When true, the
40
+ // "install" affordance hides itself.
41
+ const mq = matchMedia('(display-mode: standalone), (display-mode: window-controls-overlay)');
42
+ isInstalledPwa.value = mq.matches;
43
+ mq.addEventListener('change', () => { isInstalledPwa.value = mq.matches; });
44
+
45
+ // "is-app" body class · everything that isn't a regular browser tab
46
+ // (display-mode: browser) gets it. Used by wco.css to gate user-select
47
+ // on drag regions so chromeless --app= windows can be dragged by
48
+ // clicking the page title, while normal tabs still allow text select.
49
+ function applyIsAppClass() {
50
+ const isApp = !matchMedia('(display-mode: browser)').matches;
51
+ document.body.classList.toggle('is-app', isApp);
52
+ }
53
+ applyIsAppClass();
54
+ matchMedia('(display-mode: browser)').addEventListener('change', applyIsAppClass);
55
+
56
+ // Force-collapse the sidebar on narrow viewports. Mirrors the responsive
57
+ // CSS so JS state (toggle visibility, tree-render gating) agrees with the
58
+ // rendered layout.
59
+ const narrowMq = matchMedia('(max-width: 900px)');
60
+ function applyNarrow() { sidebarForcedCollapsed.value = narrowMq.matches; }
61
+ applyNarrow();
62
+ narrowMq.addEventListener('change', applyNarrow);
63
+
64
+ (async () => {
65
+ // Version-mismatch guard runs FIRST. If the user's backend has been
66
+ // upgraded since this per-version frontend was loaded, bounce back to
67
+ // the router immediately — no point loading config from a server that
68
+ // speaks a different API revision. Runs in dev too (it no-ops without
69
+ // the build-time <meta>).
70
+ await bootVersionGuard();
71
+
72
+ try {
73
+ await loadConfig();
74
+ await refreshAll();
75
+ pollHealth();
76
+ } catch (e) {
77
+ setToast('initial load failed · ' + e.message, 'error');
78
+ }
79
+
80
+ // 5s data refresh + clock tick (same cadence so fmtAgo "Ns ago" relative
81
+ // labels naturally track the data refresh; bumping clockTick more
82
+ // frequently would just cause needless re-renders since fmtAgo's
83
+ // resolution is coarse — 5s buckets under a minute, then m/h/d).
84
+ // loadWorkspaces is included because the workspace "in use" flag is
85
+ // derived from live session cwds server-side — without it, sessions
86
+ // move in/out of a workspace silently and the grid stays stale.
87
+ setInterval(async () => {
88
+ try {
89
+ await Promise.all([loadSessions(), loadFolders(), loadWorkspaces()]);
90
+ lastRefreshAt.value = Date.now();
91
+ } catch { /* swallow next tick retries */ }
92
+ pollHealth();
93
+ clockTick.value = Date.now();
94
+ }, 5000);
95
+
96
+ // Heartbeat · the server uses this to (a) decide whether to shut down
97
+ // when its own spawned browser closes (multi-client check), and (b) as
98
+ // a 90s watchdog backup if the browser-exit signal is missed entirely.
99
+ // 10s cadence is short enough that any tab open for one full cycle gets
100
+ // caught by the post-close decision in server.js; long enough not to be
101
+ // chatty.
102
+ const ping = () => fetch(httpBase() + '/api/heartbeat', { method: 'POST', keepalive: true }).catch(() => {});
103
+ ping();
104
+ setInterval(ping, 10_000);
105
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) ping(); });
106
+ })();
107
+
108
+ // ─── version routing guard ───────────────────────────────────────────
109
+ // Each deployed frontend is pinned to one backend version. The GH-Pages
110
+ // workflow bakes the version into <meta name="ccsm-frontend-version">
111
+ // so we can detect "backend has been upgraded since this frontend was
112
+ // loaded" and bounce back through the router at /ccsm/ for a fresh
113
+ // match. In dev (no meta tag, same-origin served-by-backend), the check
114
+ // no-ops — we're always running the frontend that ships with this
115
+ // backend by definition.
116
+ async function bootVersionGuard() {
117
+ const meta = document.querySelector('meta[name="ccsm-frontend-version"]');
118
+ if (!meta) return; // dev mode
119
+ const myVer = meta.getAttribute('content');
120
+ if (!myVer) return;
121
+ let backendVer = null;
122
+ try {
123
+ const r = await fetch(httpBase() + '/api/health', { cache: 'no-store' });
124
+ if (!r.ok) return;
125
+ backendVer = (await r.json()).version;
126
+ } catch { return; } // offline → OfflineBanner takes over
127
+ if (!backendVer || backendVer === myVer) return;
128
+ // Mismatch. Bounce up one level to the router. The router will
129
+ // probe /api/health again and redirect to ./<backendVer>/.
130
+ console.warn(`[ccsm] frontend ${myVer} ≠ backend ${backendVer} — re-routing`);
131
+ location.replace('../');
132
+ }
@@ -1,165 +1,165 @@
1
- import { html } from '../html.js';
2
- import { useEffect, useState } from 'preact/hooks';
3
- import { serverHealth, installPrompt, isInstalledPwa } from '../state.js';
4
- import { setToast } from '../toast.js';
5
- import { api } from '../api.js';
6
- import { Card } from '../components/Card.js';
7
- import { PageTitleBar } from '../components/PageTitleBar.js';
8
- import { BrandMark, IconGithub, IconExternal } from '../icons.js';
9
-
10
- const REPO_URL = 'https://github.com/bakapiano/ccsm';
11
- const NPM_URL = 'https://www.npmjs.com/package/@bakapiano/ccsm';
12
-
13
- async function onInstall() {
14
- const ev = installPrompt.value;
15
- if (!ev) return setToast('install prompt not available right now · try opening this URL in a regular Edge tab', 'error');
16
- ev.prompt();
17
- const { outcome } = await ev.userChoice;
18
- installPrompt.value = null;
19
- if (outcome === 'accepted') {
20
- setToast('installed · close + relaunch via npx ccsm to enable Window Controls Overlay');
21
- }
22
- }
23
-
24
- function InstallCard() {
25
- if (isInstalledPwa.value) return null;
26
- const canPrompt = !!installPrompt.value;
27
- return html`
28
- <${Card} title="Install as app">
29
- <p class="about-copy" style="margin-bottom: var(--s-3);">
30
- ccsm runs best as a Chromium PWA — title bar collapses into the page (Window Controls Overlay),
31
- and the launch shortcut becomes a standalone app. One-click install on supported browsers below.
32
- </p>
33
- <div class="about-links">
34
- <button class="action ${canPrompt ? 'primary' : 'subtle'}" onClick=${onInstall} disabled=${!canPrompt}>
35
- ${canPrompt ? 'Install ccsm' : 'Install not available'}
36
- </button>
37
- </div>
38
- ${!canPrompt ? html`
39
- <p class="muted-text" style="margin-top: var(--s-3);">
40
- If the button stays disabled: open <code>http://localhost:7777</code> in a regular Edge tab,
41
- click the address-bar install icon (⊕), then re-launch via <code>npx ccsm</code>.
42
- </p>` : null}
43
- </${Card}>`;
44
- }
45
-
46
- function UpgradeCard() {
47
- const [info, setInfo] = useState(null); // { current, latest, updateAvailable, fetchedAt, error? }
48
- const [checking, setChecking] = useState(true);
49
- const [upgrading, setUpgrading] = useState(false);
50
-
51
- const refresh = async (force = false) => {
52
- setChecking(true);
53
- try {
54
- const r = await api('GET', '/api/version' + (force ? '?refresh=1' : ''));
55
- setInfo(r);
56
- } catch (e) {
57
- setInfo({ error: e.message });
58
- } finally {
59
- setChecking(false);
60
- }
61
- };
62
- useEffect(() => { refresh(false); }, []);
63
-
64
- const onUpgrade = async () => {
65
- if (!info?.updateAvailable) return;
66
- setUpgrading(true);
67
- try {
68
- await api('POST', '/api/upgrade', { target: 'latest' });
69
- setToast(`upgrading to v${info.latest} · backend will restart`);
70
- } catch (e) {
71
- setUpgrading(false);
72
- setToast(e.message, 'error');
73
- }
74
- // No "finally" reset — the server is about to shut down, and the
75
- // OfflineBanner takes over UI. When the router reroutes us to the new
76
- // version's frontend, this component re-mounts fresh.
77
- };
78
-
79
- const current = info?.current || serverHealth.value.version || '';
80
- const latest = info?.latest;
81
- const updateAvailable = !!info?.updateAvailable;
82
-
83
- return html`
84
- <${Card} title="Version">
85
- <div class="about-version-row">
86
- <div>
87
- <div class="about-version-line">
88
- Installed · <span class="mono">v${current || '?'}</span>
89
- </div>
90
- ${latest && !updateAvailable ? html`
91
- <div class="muted-text" style="margin-top:4px">You're on the latest release.</div>
92
- ` : null}
93
- ${updateAvailable ? html`
94
- <div class="about-update-line">
95
- Update available · <span class="mono">v${latest}</span>
96
- </div>
97
- ` : null}
98
- ${info?.error ? html`
99
- <div class="muted-text" style="margin-top:4px">Couldn't reach npm registry.</div>
100
- ` : null}
101
- </div>
102
- <div class="about-version-actions">
103
- <button class="action subtle" onClick=${() => refresh(true)} disabled=${checking || upgrading}>
104
- ${checking ? 'Checking…' : 'Check'}
105
- </button>
106
- ${updateAvailable ? html`
107
- <button class="action primary" onClick=${onUpgrade} disabled=${upgrading}>
108
- ${upgrading ? 'Upgrading…' : `Upgrade to v${latest}`}
109
- </button>
110
- ` : null}
111
- </div>
112
- </div>
113
- ${upgrading ? html`
114
- <p class="muted-text" style="margin-top:var(--s-3)">
115
- Running <code>npm i -g @bakapiano/ccsm@latest</code>. The backend will restart automatically — you'll see the "Backend not running" screen briefly.
116
- </p>
117
- ` : null}
118
- </${Card}>`;
119
- }
120
-
121
- export function AboutPage() {
122
- const version = serverHealth.value.version;
123
-
124
- return html`
125
- <${PageTitleBar} title="About" />
126
- <${UpgradeCard} />
127
- <${InstallCard} />
128
- <${Card} title="ccsm">
129
- <div class="about-block">
130
- <div class="about-hero">
131
- <span class="about-mark"><${BrandMark} /></span>
132
- <div>
133
- <div class="about-name">ccsm <span class="about-version">${version ? `v${version}` : ''}</span></div>
134
- <div class="about-tagline">Claude Code Session Manager · a single pane over every live <code>claude</code> session on this box.</div>
135
- </div>
136
- </div>
137
-
138
- <p class="about-copy">
139
- Lists live and recently-closed sessions, snapshots them every minute, restores them through Windows Terminal,
140
- and launches fresh sessions inside isolated workspaces. Designed for running 8–10 concurrent sessions across
141
- ad-hoc repo clones.
142
- </p>
143
-
144
- <div class="about-links">
145
- <a class="action" href=${REPO_URL} target="_blank" rel="noopener">
146
- <${IconGithub} /> GitHub <${IconExternal} />
147
- </a>
148
- <a class="action subtle" href=${NPM_URL} target="_blank" rel="noopener">
149
- npm <${IconExternal} />
150
- </a>
151
- </div>
152
-
153
- <dl class="about-meta">
154
- <dt>Install</dt>
155
- <dd><code>npx @bakapiano/ccsm</code></dd>
156
- <dt>Data directory</dt>
157
- <dd><code>~/.ccsm/</code> (override with <code>CCSM_HOME</code>)</dd>
158
- <dt>Platform</dt>
159
- <dd>Windows · Node 18+</dd>
160
- <dt>License</dt>
161
- <dd>MIT</dd>
162
- </dl>
163
- </div>
164
- </${Card}>`;
165
- }
1
+ import { html } from '../html.js';
2
+ import { useEffect, useState } from 'preact/hooks';
3
+ import { serverHealth, installPrompt, isInstalledPwa } from '../state.js';
4
+ import { setToast } from '../toast.js';
5
+ import { api } from '../api.js';
6
+ import { Card } from '../components/Card.js';
7
+ import { PageTitleBar } from '../components/PageTitleBar.js';
8
+ import { BrandMark, IconGithub, IconExternal } from '../icons.js';
9
+
10
+ const REPO_URL = 'https://github.com/bakapiano/ccsm';
11
+ const NPM_URL = 'https://www.npmjs.com/package/@bakapiano/ccsm';
12
+
13
+ async function onInstall() {
14
+ const ev = installPrompt.value;
15
+ if (!ev) return setToast('install prompt not available right now · try opening this URL in a regular Edge tab', 'error');
16
+ ev.prompt();
17
+ const { outcome } = await ev.userChoice;
18
+ installPrompt.value = null;
19
+ if (outcome === 'accepted') {
20
+ setToast('installed · close + relaunch via npx ccsm to enable Window Controls Overlay');
21
+ }
22
+ }
23
+
24
+ function InstallCard() {
25
+ if (isInstalledPwa.value) return null;
26
+ const canPrompt = !!installPrompt.value;
27
+ return html`
28
+ <${Card} title="Install as app">
29
+ <p class="about-copy" style="margin-bottom: var(--s-3);">
30
+ ccsm runs best as a Chromium PWA — title bar collapses into the page (Window Controls Overlay),
31
+ and the launch shortcut becomes a standalone app. One-click install on supported browsers below.
32
+ </p>
33
+ <div class="about-links">
34
+ <button class="action ${canPrompt ? 'primary' : 'subtle'}" onClick=${onInstall} disabled=${!canPrompt}>
35
+ ${canPrompt ? 'Install ccsm' : 'Install not available'}
36
+ </button>
37
+ </div>
38
+ ${!canPrompt ? html`
39
+ <p class="muted-text" style="margin-top: var(--s-3);">
40
+ If the button stays disabled: open <code>http://localhost:7777</code> in a regular Edge tab,
41
+ click the address-bar install icon (⊕), then re-launch via <code>npx ccsm</code>.
42
+ </p>` : null}
43
+ </${Card}>`;
44
+ }
45
+
46
+ function UpgradeCard() {
47
+ const [info, setInfo] = useState(null); // { current, latest, updateAvailable, fetchedAt, error? }
48
+ const [checking, setChecking] = useState(true);
49
+ const [upgrading, setUpgrading] = useState(false);
50
+
51
+ const refresh = async (force = false) => {
52
+ setChecking(true);
53
+ try {
54
+ const r = await api('GET', '/api/version' + (force ? '?refresh=1' : ''));
55
+ setInfo(r);
56
+ } catch (e) {
57
+ setInfo({ error: e.message });
58
+ } finally {
59
+ setChecking(false);
60
+ }
61
+ };
62
+ useEffect(() => { refresh(false); }, []);
63
+
64
+ const onUpgrade = async () => {
65
+ if (!info?.updateAvailable) return;
66
+ setUpgrading(true);
67
+ try {
68
+ await api('POST', '/api/upgrade', { target: 'latest' });
69
+ setToast(`upgrading to v${info.latest} · backend will restart`);
70
+ } catch (e) {
71
+ setUpgrading(false);
72
+ setToast(e.message, 'error');
73
+ }
74
+ // No "finally" reset — the server is about to shut down, and the
75
+ // OfflineBanner takes over UI. When the router reroutes us to the new
76
+ // version's frontend, this component re-mounts fresh.
77
+ };
78
+
79
+ const current = info?.current || serverHealth.value.version || '';
80
+ const latest = info?.latest;
81
+ const updateAvailable = !!info?.updateAvailable;
82
+
83
+ return html`
84
+ <${Card} title="Version">
85
+ <div class="about-version-row">
86
+ <div>
87
+ <div class="about-version-line">
88
+ Installed · <span class="mono">v${current || '?'}</span>
89
+ </div>
90
+ ${latest && !updateAvailable ? html`
91
+ <div class="muted-text" style="margin-top:4px">You're on the latest release.</div>
92
+ ` : null}
93
+ ${updateAvailable ? html`
94
+ <div class="about-update-line">
95
+ Update available · <span class="mono">v${latest}</span>
96
+ </div>
97
+ ` : null}
98
+ ${info?.error ? html`
99
+ <div class="muted-text" style="margin-top:4px">Couldn't reach npm registry.</div>
100
+ ` : null}
101
+ </div>
102
+ <div class="about-version-actions">
103
+ <button class="action subtle" onClick=${() => refresh(true)} disabled=${checking || upgrading}>
104
+ ${checking ? 'Checking…' : 'Check'}
105
+ </button>
106
+ ${updateAvailable ? html`
107
+ <button class="action primary" onClick=${onUpgrade} disabled=${upgrading}>
108
+ ${upgrading ? 'Upgrading…' : `Upgrade to v${latest}`}
109
+ </button>
110
+ ` : null}
111
+ </div>
112
+ </div>
113
+ ${upgrading ? html`
114
+ <p class="muted-text" style="margin-top:var(--s-3)">
115
+ Running <code>npm i -g @bakapiano/ccsm@latest</code>. The backend will restart automatically — you'll see the "Backend not running" screen briefly.
116
+ </p>
117
+ ` : null}
118
+ </${Card}>`;
119
+ }
120
+
121
+ export function AboutPage() {
122
+ const version = serverHealth.value.version;
123
+
124
+ return html`
125
+ <${PageTitleBar} title="About" />
126
+ <${UpgradeCard} />
127
+ <${InstallCard} />
128
+ <${Card} title="ccsm">
129
+ <div class="about-block">
130
+ <div class="about-hero">
131
+ <span class="about-mark"><${BrandMark} /></span>
132
+ <div>
133
+ <div class="about-name">ccsm <span class="about-version">${version ? `v${version}` : ''}</span></div>
134
+ <div class="about-tagline">Claude Code Session Manager · a single pane over every live <code>claude</code> session on this box.</div>
135
+ </div>
136
+ </div>
137
+
138
+ <p class="about-copy">
139
+ Lists live and recently-closed sessions, snapshots them every minute, restores them through Windows Terminal,
140
+ and launches fresh sessions inside isolated workspaces. Designed for running 8–10 concurrent sessions across
141
+ ad-hoc repo clones.
142
+ </p>
143
+
144
+ <div class="about-links">
145
+ <a class="action" href=${REPO_URL} target="_blank" rel="noopener">
146
+ <${IconGithub} /> GitHub <${IconExternal} />
147
+ </a>
148
+ <a class="action subtle" href=${NPM_URL} target="_blank" rel="noopener">
149
+ npm <${IconExternal} />
150
+ </a>
151
+ </div>
152
+
153
+ <dl class="about-meta">
154
+ <dt>Install</dt>
155
+ <dd><code>npx @bakapiano/ccsm</code></dd>
156
+ <dt>Data directory</dt>
157
+ <dd><code>~/.ccsm/</code> (override with <code>CCSM_HOME</code>)</dd>
158
+ <dt>Platform</dt>
159
+ <dd>Windows · Node 18+</dd>
160
+ <dt>License</dt>
161
+ <dd>MIT</dd>
162
+ </dl>
163
+ </div>
164
+ </${Card}>`;
165
+ }