@bakapiano/ccsm 0.10.3 → 0.11.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 (48) hide show
  1. package/CLAUDE.md +475 -475
  2. package/README.md +190 -190
  3. package/bin/ccsm.js +194 -194
  4. package/lib/cliSessionWatcher.js +249 -249
  5. package/lib/config.js +185 -185
  6. package/lib/folders.js +96 -96
  7. package/lib/localCliSessions.js +489 -177
  8. package/lib/persistedSessions.js +134 -134
  9. package/lib/webTerminal.js +208 -208
  10. package/lib/workspace.js +230 -255
  11. package/package.json +57 -57
  12. package/public/css/base.css +99 -99
  13. package/public/css/cards.css +183 -183
  14. package/public/css/feedback.css +303 -303
  15. package/public/css/forms.css +405 -405
  16. package/public/css/layout.css +160 -160
  17. package/public/css/modal.css +190 -183
  18. package/public/css/responsive.css +10 -10
  19. package/public/css/sidebar.css +616 -601
  20. package/public/css/terminals.css +294 -294
  21. package/public/css/tokens.css +81 -79
  22. package/public/css/wco.css +98 -98
  23. package/public/css/widgets.css +1596 -1375
  24. package/public/index.html +105 -103
  25. package/public/js/api.js +272 -260
  26. package/public/js/components/AdoptModal.js +343 -171
  27. package/public/js/components/App.js +35 -35
  28. package/public/js/components/DirectoryPicker.js +203 -203
  29. package/public/js/components/EntityFormModal.js +105 -105
  30. package/public/js/components/Modal.js +51 -51
  31. package/public/js/components/OfflineBanner.js +93 -93
  32. package/public/js/components/PageTitleBar.js +13 -13
  33. package/public/js/components/Picker.js +179 -179
  34. package/public/js/components/Popover.js +55 -55
  35. package/public/js/components/Sidebar.js +270 -270
  36. package/public/js/components/TerminalView.js +298 -298
  37. package/public/js/components/useDragSort.js +67 -67
  38. package/public/js/dialog.js +67 -67
  39. package/public/js/icons.js +177 -177
  40. package/public/js/main.js +140 -140
  41. package/public/js/pages/AboutPage.js +165 -165
  42. package/public/js/pages/ConfigurePage.js +475 -487
  43. package/public/js/pages/LaunchPage.js +369 -369
  44. package/public/js/pages/SessionsPage.js +97 -97
  45. package/public/js/state.js +231 -231
  46. package/public/manifest.webmanifest +15 -15
  47. package/scripts/install.js +137 -137
  48. package/server.js +1126 -1117
package/public/js/main.js CHANGED
@@ -1,140 +1,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 { 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 coarse — 5s 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 { 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 coarse — 5s 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,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
+ }