@bakapiano/ccsm 0.14.0 → 0.15.1

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 (53) hide show
  1. package/CLAUDE.md +474 -475
  2. package/README.md +189 -190
  3. package/bin/ccsm.js +194 -194
  4. package/lib/cliActivity.js +118 -0
  5. package/lib/codexSeed.js +147 -0
  6. package/lib/config.js +205 -188
  7. package/lib/folders.js +105 -105
  8. package/lib/localCliSessions.js +489 -489
  9. package/lib/persistedSessions.js +144 -142
  10. package/lib/webTerminal.js +224 -224
  11. package/lib/workspace.js +230 -230
  12. package/package.json +57 -57
  13. package/public/css/base.css +99 -99
  14. package/public/css/cards.css +183 -183
  15. package/public/css/feedback.css +303 -303
  16. package/public/css/forms.css +405 -405
  17. package/public/css/layout.css +160 -160
  18. package/public/css/modal.css +190 -190
  19. package/public/css/responsive.css +10 -10
  20. package/public/css/sidebar.css +613 -608
  21. package/public/css/terminals.css +294 -294
  22. package/public/css/tokens.css +81 -81
  23. package/public/css/wco.css +98 -98
  24. package/public/css/widgets.css +1628 -1628
  25. package/public/index.html +111 -105
  26. package/public/js/api.js +296 -280
  27. package/public/js/components/AdoptModal.js +343 -343
  28. package/public/js/components/App.js +35 -35
  29. package/public/js/components/DirectoryPicker.js +203 -203
  30. package/public/js/components/EntityFormModal.js +141 -141
  31. package/public/js/components/Modal.js +51 -51
  32. package/public/js/components/OfflineBanner.js +93 -93
  33. package/public/js/components/PageTitleBar.js +13 -13
  34. package/public/js/components/Picker.js +179 -179
  35. package/public/js/components/Popover.js +55 -55
  36. package/public/js/components/Sidebar.js +299 -299
  37. package/public/js/components/TerminalView.js +314 -314
  38. package/public/js/components/useDragSort.js +67 -67
  39. package/public/js/dialog.js +67 -67
  40. package/public/js/icons.js +177 -177
  41. package/public/js/main.js +132 -132
  42. package/public/js/pages/AboutPage.js +173 -165
  43. package/public/js/pages/ConfigurePage.js +513 -475
  44. package/public/js/pages/LaunchPage.js +369 -369
  45. package/public/js/pages/SessionsPage.js +101 -97
  46. package/public/js/state.js +231 -231
  47. package/scripts/dev.js +44 -11
  48. package/scripts/install.js +158 -158
  49. package/scripts/restart-helper.js +96 -0
  50. package/scripts/upgrade-helper.js +6 -1
  51. package/server.js +1282 -1254
  52. package/lib/cliSessionWatcher.js +0 -275
  53. package/public/manifest.webmanifest +0 -15
package/public/js/main.js CHANGED
@@ -1,132 +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 { 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
+ // 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,173 @@
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
+ const r = await api('POST', '/api/upgrade', { target: 'latest' });
69
+ setToast(`upgrading to v${info.latest} · backend will restart`);
70
+ if (r?.closeFrontend) {
71
+ // Backend will respawn with a fresh browser window — close this
72
+ // one so the user isn't stuck on the OfflineBanner during the
73
+ // upgrade window. window.close() only works when the window was
74
+ // script-opened (Edge --app=, our spawned browser); regular tabs
75
+ // ignore it silently, which is fine (OfflineBanner takes over).
76
+ setTimeout(() => { try { window.close(); } catch {} }, 400);
77
+ }
78
+ } catch (e) {
79
+ setUpgrading(false);
80
+ setToast(e.message, 'error');
81
+ }
82
+ // No "finally" reset — the server is about to shut down, and the
83
+ // OfflineBanner takes over UI. When the router reroutes us to the new
84
+ // version's frontend, this component re-mounts fresh.
85
+ };
86
+
87
+ const current = info?.current || serverHealth.value.version || '';
88
+ const latest = info?.latest;
89
+ const updateAvailable = !!info?.updateAvailable;
90
+
91
+ return html`
92
+ <${Card} title="Version">
93
+ <div class="about-version-row">
94
+ <div>
95
+ <div class="about-version-line">
96
+ Installed · <span class="mono">v${current || '?'}</span>
97
+ </div>
98
+ ${latest && !updateAvailable ? html`
99
+ <div class="muted-text" style="margin-top:4px">You're on the latest release.</div>
100
+ ` : null}
101
+ ${updateAvailable ? html`
102
+ <div class="about-update-line">
103
+ Update available · <span class="mono">v${latest}</span>
104
+ </div>
105
+ ` : null}
106
+ ${info?.error ? html`
107
+ <div class="muted-text" style="margin-top:4px">Couldn't reach npm registry.</div>
108
+ ` : null}
109
+ </div>
110
+ <div class="about-version-actions">
111
+ <button class="action subtle" onClick=${() => refresh(true)} disabled=${checking || upgrading}>
112
+ ${checking ? 'Checking…' : 'Check'}
113
+ </button>
114
+ ${updateAvailable ? html`
115
+ <button class="action primary" onClick=${onUpgrade} disabled=${upgrading}>
116
+ ${upgrading ? 'Upgrading…' : `Upgrade to v${latest}`}
117
+ </button>
118
+ ` : null}
119
+ </div>
120
+ </div>
121
+ ${upgrading ? html`
122
+ <p class="muted-text" style="margin-top:var(--s-3)">
123
+ Running <code>npm i -g @bakapiano/ccsm@latest</code>. The backend will restart automatically — you'll see the "Backend not running" screen briefly.
124
+ </p>
125
+ ` : null}
126
+ </${Card}>`;
127
+ }
128
+
129
+ export function AboutPage() {
130
+ const version = serverHealth.value.version;
131
+
132
+ return html`
133
+ <${PageTitleBar} title="About" />
134
+ <${UpgradeCard} />
135
+ <${InstallCard} />
136
+ <${Card} title="ccsm">
137
+ <div class="about-block">
138
+ <div class="about-hero">
139
+ <span class="about-mark"><${BrandMark} /></span>
140
+ <div>
141
+ <div class="about-name">ccsm <span class="about-version">${version ? `v${version}` : ''}</span></div>
142
+ <div class="about-tagline">Claude Code Session Manager · a single pane over every live <code>claude</code> session on this box.</div>
143
+ </div>
144
+ </div>
145
+
146
+ <p class="about-copy">
147
+ Lists live and recently-closed sessions, snapshots them every minute, restores them through Windows Terminal,
148
+ and launches fresh sessions inside isolated workspaces. Designed for running 8–10 concurrent sessions across
149
+ ad-hoc repo clones.
150
+ </p>
151
+
152
+ <div class="about-links">
153
+ <a class="action" href=${REPO_URL} target="_blank" rel="noopener">
154
+ <${IconGithub} /> GitHub <${IconExternal} />
155
+ </a>
156
+ <a class="action subtle" href=${NPM_URL} target="_blank" rel="noopener">
157
+ npm <${IconExternal} />
158
+ </a>
159
+ </div>
160
+
161
+ <dl class="about-meta">
162
+ <dt>Install</dt>
163
+ <dd><code>npx @bakapiano/ccsm</code></dd>
164
+ <dt>Data directory</dt>
165
+ <dd><code>~/.ccsm/</code> (override with <code>CCSM_HOME</code>)</dd>
166
+ <dt>Platform</dt>
167
+ <dd>Windows · Node 18+</dd>
168
+ <dt>License</dt>
169
+ <dd>MIT</dd>
170
+ </dl>
171
+ </div>
172
+ </${Card}>`;
173
+ }