@bakapiano/ccsm 0.22.6 → 0.22.7
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.
- package/CLAUDE.md +538 -538
- package/README.md +189 -189
- package/bin/ccsm.js +235 -235
- package/lib/cliActivity.js +139 -139
- package/lib/codexSeed.js +183 -183
- package/lib/config.js +279 -274
- package/lib/devices.js +229 -229
- package/lib/folders.js +124 -124
- package/lib/localCliSessions.js +519 -519
- package/lib/persistedSessions.js +129 -129
- package/lib/tunnel.js +621 -621
- package/lib/webTerminal.js +225 -225
- package/lib/workspace.js +233 -233
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +504 -504
- package/public/css/forms.css +453 -453
- package/public/css/layout.css +154 -154
- package/public/css/modal.css +190 -190
- package/public/css/responsive.css +176 -176
- package/public/css/sidebar.css +707 -707
- package/public/css/terminals.css +546 -546
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +196 -196
- package/public/css/widgets.css +2725 -2725
- package/public/index.html +152 -152
- package/public/js/api.js +371 -371
- package/public/js/backend.js +149 -149
- package/public/js/components/App.js +73 -73
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +153 -153
- package/public/js/components/Modal.js +57 -57
- package/public/js/components/OfflineBanner.js +67 -67
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/PendingApprovalOverlay.js +128 -128
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/RestartOverlay.js +36 -36
- package/public/js/components/Sidebar.js +380 -380
- package/public/js/components/TerminalInstance.js +28 -0
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +212 -212
- package/public/js/main.js +296 -296
- package/public/js/pages/AboutPage.js +90 -90
- package/public/js/pages/ConfigurePage.js +728 -713
- package/public/js/pages/LaunchPage.js +421 -421
- package/public/js/pages/RemotePage.js +743 -743
- package/public/js/pages/SessionsPage.js +53 -53
- package/public/js/state.js +335 -335
- package/scripts/dev.js +149 -149
- package/scripts/install.js +153 -153
- package/scripts/restart-helper.js +96 -96
- package/scripts/upgrade-helper.js +687 -687
- package/server.js +1820 -1807
- package/public/manifest.webmanifest +0 -25
- package/public/setup/index.html +0 -567
package/public/js/state.js
CHANGED
|
@@ -1,335 +1,335 @@
|
|
|
1
|
-
// All shared reactive state. Importing a signal anywhere subscribes the
|
|
2
|
-
// reading component, so we never need a store / context wrapper.
|
|
3
|
-
|
|
4
|
-
import { signal, computed } from '@preact/signals';
|
|
5
|
-
|
|
6
|
-
// ── server-driven data ──────────────────────────────────────────
|
|
7
|
-
export const config = signal(null);
|
|
8
|
-
export const capabilities = signal({ webTerminal: false });
|
|
9
|
-
// `sessions` is the ccsm-persisted list (lib/persistedSessions). Every
|
|
10
|
-
// entry has { id, cliId, cwd, workspace, title, folderId, repos,
|
|
11
|
-
// createdAt, lastActiveAt, status, exitedAt, exitCode, pid }.
|
|
12
|
-
export const sessions = signal([]);
|
|
13
|
-
export const folders = signal([]); // [{id,name,order,createdAt}]
|
|
14
|
-
export const workspaces = signal([]);
|
|
15
|
-
export const serverHealth = signal({ state: 'connecting' });
|
|
16
|
-
// Flips true the first time we successfully reach the backend in this
|
|
17
|
-
// frontend session. Gates UI (HealthOverlay) so it doesn't pop on the
|
|
18
|
-
// very first boot probe while the page is still wiring up.
|
|
19
|
-
export const hasBootedOnline = signal(false);
|
|
20
|
-
// Set true the moment the user clicks "Restart backend" — the
|
|
21
|
-
// RestartOverlay reads this signal and blocks the whole page until
|
|
22
|
-
// the next health poll returns a fresh PID. Cleared by the overlay
|
|
23
|
-
// itself on reconnect. Kept here (not in ConfigurePage local state)
|
|
24
|
-
// so a stale tab on another page can't miss the in-flight restart.
|
|
25
|
-
export const restartInFlight = signal(null); // { startedAt, prevPid } | null
|
|
26
|
-
|
|
27
|
-
// ── ui state (persisted in localStorage where noted) ───────────
|
|
28
|
-
export const activeTab = signal('sessions');
|
|
29
|
-
export const activeSessionId = signal(null); // the session currently rendered in the right pane
|
|
30
|
-
export const sidebarCollapsed = signal(false);
|
|
31
|
-
// True when viewport is narrow enough that the sidebar is force-collapsed
|
|
32
|
-
// by the responsive layout — the toggle button hides in that case so the
|
|
33
|
-
// user can't try (and fail) to expand it.
|
|
34
|
-
export const sidebarForcedCollapsed = signal(false);
|
|
35
|
-
// True on phone-sized viewports (≤ 640px). The sidebar then hides
|
|
36
|
-
// entirely; a FAB at bottom-left opens a full-screen drawer.
|
|
37
|
-
export const isMobile = signal(false);
|
|
38
|
-
// Mobile drawer visibility — toggled by the FAB / nav-item taps.
|
|
39
|
-
export const mobileDrawerOpen = signal(false);
|
|
40
|
-
export const sidebarWidth = signal(232); // px when expanded, persisted in localStorage
|
|
41
|
-
export const accentColor = signal('#2f6fa3'); // user-chosen brand accent, persisted
|
|
42
|
-
export const themeMode = signal('system'); // 'light' | 'dark' | 'system', persisted
|
|
43
|
-
// Per-folder collapse state in the sidebar tree. Stored as a plain object
|
|
44
|
-
// {folderId: true} (true = collapsed). Key 'unsorted' covers the implicit
|
|
45
|
-
// Unsorted bucket.
|
|
46
|
-
export const foldersCollapsed = signal({});
|
|
47
|
-
export const configDirty = signal(false);
|
|
48
|
-
// Per-card fold state on pages that use the <Card> component. The card
|
|
49
|
-
// just toggles a key here; persistence is best-effort via localStorage
|
|
50
|
-
// under `ccsm.fold.<key>` (set by toggleCardFold).
|
|
51
|
-
export const cardFolded = signal({});
|
|
52
|
-
export const clockTick = signal(Date.now()); // re-ticked each second so fmtAgo refreshes
|
|
53
|
-
export const lastRefreshAt = signal(0); // ms timestamp of last successful refreshAll()
|
|
54
|
-
export const installPrompt = signal(null); // captured beforeinstallprompt event (PWA install)
|
|
55
|
-
export const isInstalledPwa = signal(false); // running inside an installed PWA window
|
|
56
|
-
|
|
57
|
-
// ── derived ─────────────────────────────────────────────────────
|
|
58
|
-
// Group sessions by folder, with a synthetic "unsorted" bucket for those
|
|
59
|
-
// without a folderId. Folders define the rendering order; sessions
|
|
60
|
-
// inside each are sorted by createdAt desc (stable — using lastActiveAt
|
|
61
|
-
// would make rows jump on resume).
|
|
62
|
-
//
|
|
63
|
-
// We pre-create a bucket per declared session.folderId even if the
|
|
64
|
-
// matching folder hasn't loaded yet — that way on first paint sessions
|
|
65
|
-
// don't all collapse into Unsorted and then snap back into their real
|
|
66
|
-
// folder a few ms later when /api/folders resolves.
|
|
67
|
-
// "Unsorted" is keyed as 'unsorted' (not null) so it can be looked up
|
|
68
|
-
// alongside real folders by Sidebar/keybindings iterating folders.value
|
|
69
|
-
// — backend exposes a synthetic folder with id='unsorted' that's always
|
|
70
|
-
// present, drag-reorderable like real folders.
|
|
71
|
-
export const UNSORTED_KEY = 'unsorted';
|
|
72
|
-
export const sessionsByFolder = computed(() => {
|
|
73
|
-
const groups = new Map();
|
|
74
|
-
groups.set(UNSORTED_KEY, []);
|
|
75
|
-
for (const f of folders.value) groups.set(f.id, []);
|
|
76
|
-
for (const s of sessions.value) {
|
|
77
|
-
const key = s.folderId || UNSORTED_KEY;
|
|
78
|
-
if (!groups.has(key)) groups.set(key, []);
|
|
79
|
-
groups.get(key).push(s);
|
|
80
|
-
}
|
|
81
|
-
for (const list of groups.values()) {
|
|
82
|
-
// Stable sort: explicit `order` field first (set by user drag), then
|
|
83
|
-
// createdAt desc as fallback. Sessions without `order` fall to the
|
|
84
|
-
// top (newer-first) which is the legacy behavior.
|
|
85
|
-
list.sort((a, b) => {
|
|
86
|
-
const oa = typeof a.order === 'number' ? a.order : null;
|
|
87
|
-
const ob = typeof b.order === 'number' ? b.order : null;
|
|
88
|
-
if (oa !== null && ob !== null) return oa - ob;
|
|
89
|
-
if (oa !== null) return -1;
|
|
90
|
-
if (ob !== null) return 1;
|
|
91
|
-
return (b.createdAt || 0) - (a.createdAt || 0);
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
return groups;
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
export const TAB_HEADINGS = {
|
|
98
|
-
sessions: { title: 'Sessions', subtitle: 'Sessions you started in ccsm.' },
|
|
99
|
-
launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace.' },
|
|
100
|
-
configure: { title: 'Configure', subtitle: 'Persisted to ~/.ccsm/config.json.' },
|
|
101
|
-
remote: { title: 'Remote', subtitle: 'Expose this backend to another device via tunnel + token.' },
|
|
102
|
-
about: { title: 'About', subtitle: 'ccsm — Claude CLI Sessions Manager.' },
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
// ── persistence helpers (localStorage) ──────────────────────────
|
|
106
|
-
const LS_SIDEBAR = 'ccsm.sidebar-collapsed';
|
|
107
|
-
const LS_SIDEBAR_W = 'ccsm.sidebar-width';
|
|
108
|
-
const LS_ACCENT = 'ccsm.accent';
|
|
109
|
-
const LS_THEME = 'ccsm.theme';
|
|
110
|
-
const LS_FOLDERS_COLLAPSED = 'ccsm.folders-collapsed';
|
|
111
|
-
// Last-known sidebar tree, rehydrated on boot to keep the first paint
|
|
112
|
-
// stable. The next refreshAll() overwrites these from the server, so
|
|
113
|
-
// stale entries self-heal within ~5s without any explicit invalidation.
|
|
114
|
-
const LS_FOLDERS_CACHE = 'ccsm.folders-cache';
|
|
115
|
-
const LS_SESSIONS_CACHE = 'ccsm.sessions-cache';
|
|
116
|
-
|
|
117
|
-
export const SIDEBAR_MIN = 180;
|
|
118
|
-
export const SIDEBAR_MAX = 400;
|
|
119
|
-
export const SIDEBAR_DEFAULT = 232;
|
|
120
|
-
export const ACCENT_DEFAULT = '#2f6fa3';
|
|
121
|
-
|
|
122
|
-
export function loadPersisted() {
|
|
123
|
-
sidebarCollapsed.value = localStorage.getItem(LS_SIDEBAR) === 'true';
|
|
124
|
-
const w = Number(localStorage.getItem(LS_SIDEBAR_W));
|
|
125
|
-
if (Number.isFinite(w) && w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) {
|
|
126
|
-
sidebarWidth.value = w;
|
|
127
|
-
}
|
|
128
|
-
applySidebarWidthCssVar();
|
|
129
|
-
const a = localStorage.getItem(LS_ACCENT);
|
|
130
|
-
if (isHexColor(a)) accentColor.value = a;
|
|
131
|
-
const t = localStorage.getItem(LS_THEME);
|
|
132
|
-
if (t === 'light' || t === 'dark' || t === 'system') themeMode.value = t;
|
|
133
|
-
applyTheme();
|
|
134
|
-
try {
|
|
135
|
-
const raw = localStorage.getItem(LS_FOLDERS_COLLAPSED);
|
|
136
|
-
if (raw) {
|
|
137
|
-
const parsed = JSON.parse(raw);
|
|
138
|
-
if (parsed && typeof parsed === 'object') foldersCollapsed.value = parsed;
|
|
139
|
-
}
|
|
140
|
-
} catch {}
|
|
141
|
-
// Rehydrate the sidebar tree from the last seen server state so
|
|
142
|
-
// the first paint matches the user's last view. refreshAll() arrives
|
|
143
|
-
// ~50–500ms later and overwrites with fresh data.
|
|
144
|
-
try {
|
|
145
|
-
const raw = localStorage.getItem(LS_FOLDERS_CACHE);
|
|
146
|
-
if (raw) {
|
|
147
|
-
const parsed = JSON.parse(raw);
|
|
148
|
-
if (Array.isArray(parsed)) folders.value = parsed;
|
|
149
|
-
}
|
|
150
|
-
} catch {}
|
|
151
|
-
try {
|
|
152
|
-
const raw = localStorage.getItem(LS_SESSIONS_CACHE);
|
|
153
|
-
if (raw) {
|
|
154
|
-
const parsed = JSON.parse(raw);
|
|
155
|
-
if (Array.isArray(parsed)) sessions.value = parsed;
|
|
156
|
-
}
|
|
157
|
-
} catch {}
|
|
158
|
-
const hash = location.hash.slice(1);
|
|
159
|
-
if (TAB_HEADINGS[hash]) activeTab.value = hash;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function applySidebarWidthCssVar() {
|
|
163
|
-
document.documentElement.style.setProperty('--sidebar-w', `${sidebarWidth.value}px`);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function setSidebarWidth(px) {
|
|
167
|
-
const clamped = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, Math.round(px)));
|
|
168
|
-
sidebarWidth.value = clamped;
|
|
169
|
-
applySidebarWidthCssVar();
|
|
170
|
-
localStorage.setItem(LS_SIDEBAR_W, String(clamped));
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// ── theme (accent + light/dark) ─────────────────────────────────
|
|
174
|
-
function isHexColor(s) {
|
|
175
|
-
return typeof s === 'string' && /^#[0-9a-fA-F]{6}$/.test(s);
|
|
176
|
-
}
|
|
177
|
-
function hexToRgb(hex) {
|
|
178
|
-
const n = parseInt(hex.slice(1), 16);
|
|
179
|
-
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
|
180
|
-
}
|
|
181
|
-
function rgbToHex({ r, g, b }) {
|
|
182
|
-
const h = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
|
|
183
|
-
return `#${h(r)}${h(g)}${h(b)}`;
|
|
184
|
-
}
|
|
185
|
-
// Linear blend c1→c2 by t∈[0,1]. t=0 yields c1, t=1 yields c2.
|
|
186
|
-
function lerp(c1, c2, t) {
|
|
187
|
-
return { r: c1.r + (c2.r - c1.r) * t, g: c1.g + (c2.g - c1.g) * t, b: c1.b + (c2.b - c1.b) * t };
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Anchor colors the palette is derived from. Light mode mixes the accent
|
|
191
|
-
// toward WHITE for surfaces and keeps warm-dark ink; dark mode mixes the
|
|
192
|
-
// accent toward DARK for surfaces and uses warm-light ink — same accent,
|
|
193
|
-
// inverted ground. Keep these in sync with the pre-paint script in
|
|
194
|
-
// public/index.html (it re-derives the same values to avoid a FOUC).
|
|
195
|
-
const WHITE = { r: 255, g: 255, b: 255 };
|
|
196
|
-
const DARK_BASE = { r: 0x18, g: 0x16, b: 0x12 }; // #181612 warm near-black
|
|
197
|
-
const LIGHT_INK = { r: 0xec, g: 0xe7, b: 0xda }; // #ece7da warm light text
|
|
198
|
-
|
|
199
|
-
// True when the effective theme is dark. 'system' consults the OS.
|
|
200
|
-
function resolveDark(mode) {
|
|
201
|
-
if (mode === 'dark') return true;
|
|
202
|
-
if (mode === 'light') return false;
|
|
203
|
-
return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function applyAccentCssVars() {
|
|
207
|
-
const base = accentColor.value;
|
|
208
|
-
const A = hexToRgb(base);
|
|
209
|
-
const dark = resolveDark(themeMode.value);
|
|
210
|
-
const root = document.documentElement.style;
|
|
211
|
-
let vars;
|
|
212
|
-
if (dark) {
|
|
213
|
-
const bg = lerp(DARK_BASE, A, 0.06); // dark ground, faint accent tint
|
|
214
|
-
const lift = (t) => rgbToHex(lerp(bg, LIGHT_INK, t)); // raise toward light
|
|
215
|
-
vars = {
|
|
216
|
-
'--accent': base,
|
|
217
|
-
'--accent-deep': rgbToHex(lerp(A, LIGHT_INK, 0.18)), // brighter on dark
|
|
218
|
-
'--accent-soft': `rgba(${A.r}, ${A.g}, ${A.b}, 0.18)`,
|
|
219
|
-
'--accent-softer': `rgba(${A.r}, ${A.g}, ${A.b}, 0.07)`,
|
|
220
|
-
'--bg': rgbToHex(bg),
|
|
221
|
-
'--bg-elev': lift(0.05),
|
|
222
|
-
'--sidebar-bg': rgbToHex(bg),
|
|
223
|
-
'--sidebar-hover': lift(0.09),
|
|
224
|
-
'--sidebar-active': lift(0.15),
|
|
225
|
-
'--border': lift(0.14),
|
|
226
|
-
'--border-soft': lift(0.09),
|
|
227
|
-
'--border-strong': lift(0.24),
|
|
228
|
-
'--ui-bg': lift(0.05),
|
|
229
|
-
'--ui-border': lift(0.16),
|
|
230
|
-
'--ui-border-soft': lift(0.10),
|
|
231
|
-
'--ink': rgbToHex(LIGHT_INK),
|
|
232
|
-
'--ink-mid': rgbToHex(lerp(LIGHT_INK, DARK_BASE, 0.28)),
|
|
233
|
-
'--ink-muted': rgbToHex(lerp(LIGHT_INK, DARK_BASE, 0.45)),
|
|
234
|
-
'--ink-faint': rgbToHex(lerp(LIGHT_INK, DARK_BASE, 0.60)),
|
|
235
|
-
};
|
|
236
|
-
} else {
|
|
237
|
-
const mix = (t) => rgbToHex(lerp(WHITE, A, t)); // light ground, accent tint
|
|
238
|
-
vars = {
|
|
239
|
-
'--accent': base,
|
|
240
|
-
'--accent-deep': rgbToHex(lerp(A, { r: 0, g: 0, b: 0 }, 0.2)),
|
|
241
|
-
'--accent-soft': `rgba(${A.r}, ${A.g}, ${A.b}, 0.10)`,
|
|
242
|
-
'--accent-softer': `rgba(${A.r}, ${A.g}, ${A.b}, 0.04)`,
|
|
243
|
-
'--bg': mix(0.04),
|
|
244
|
-
'--bg-elev': '#ffffff',
|
|
245
|
-
'--sidebar-bg': mix(0.04),
|
|
246
|
-
'--sidebar-hover': mix(0.10),
|
|
247
|
-
'--sidebar-active': mix(0.15),
|
|
248
|
-
'--border': mix(0.15),
|
|
249
|
-
'--border-soft': mix(0.12),
|
|
250
|
-
'--border-strong': mix(0.25),
|
|
251
|
-
'--ui-bg': mix(0.10),
|
|
252
|
-
'--ui-border': '#d8d4c6', // theme-independent neutral
|
|
253
|
-
'--ui-border-soft': '#e6e2d4', // theme-independent neutral
|
|
254
|
-
'--ink': '#1a1815',
|
|
255
|
-
'--ink-mid': '#534e44',
|
|
256
|
-
'--ink-muted': '#8a8475',
|
|
257
|
-
'--ink-faint': '#b5af9d',
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
for (const [k, v] of Object.entries(vars)) root.setProperty(k, v);
|
|
261
|
-
const meta = document.querySelector('meta[name="theme-color"]');
|
|
262
|
-
if (meta) meta.setAttribute('content', vars['--bg']);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Set data-theme on <html> (drives the [data-theme="dark"] CSS overrides)
|
|
266
|
-
// and re-derive the accent-tinted palette for the resolved theme.
|
|
267
|
-
function applyTheme() {
|
|
268
|
-
const dark = resolveDark(themeMode.value);
|
|
269
|
-
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
|
270
|
-
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
|
271
|
-
applyAccentCssVars();
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// React to OS theme changes while in 'system' mode.
|
|
275
|
-
if (window.matchMedia) {
|
|
276
|
-
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
277
|
-
if (themeMode.value === 'system') applyTheme();
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Resolved theme for non-CSS consumers (e.g. the xterm canvas, which is
|
|
282
|
-
// painted from a JS color object, not CSS vars).
|
|
283
|
-
export function isDarkTheme() {
|
|
284
|
-
return resolveDark(themeMode.value);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
export function setThemeMode(mode) {
|
|
288
|
-
if (mode !== 'light' && mode !== 'dark' && mode !== 'system') return;
|
|
289
|
-
themeMode.value = mode;
|
|
290
|
-
applyTheme();
|
|
291
|
-
localStorage.setItem(LS_THEME, mode);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
export function setAccentColor(hex) {
|
|
295
|
-
if (!isHexColor(hex)) return;
|
|
296
|
-
accentColor.value = hex;
|
|
297
|
-
applyAccentCssVars();
|
|
298
|
-
localStorage.setItem(LS_ACCENT, hex);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// ── actions ─────────────────────────────────────────────────────
|
|
302
|
-
export function selectTab(name) {
|
|
303
|
-
if (!TAB_HEADINGS[name]) name = 'sessions';
|
|
304
|
-
activeTab.value = name;
|
|
305
|
-
if (location.hash !== `#${name}`) window.history.replaceState(null, '', `#${name}`);
|
|
306
|
-
// Tapping a nav item on mobile is also a "close the drawer" gesture
|
|
307
|
-
// — the user got what they came for, no need to keep the overlay up.
|
|
308
|
-
if (mobileDrawerOpen.value) mobileDrawerOpen.value = false;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export function selectSession(id) {
|
|
312
|
-
activeSessionId.value = id;
|
|
313
|
-
activeTab.value = 'sessions';
|
|
314
|
-
if (location.hash !== '#sessions') window.history.replaceState(null, '', '#sessions');
|
|
315
|
-
if (mobileDrawerOpen.value) mobileDrawerOpen.value = false;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
export function toggleSidebar() {
|
|
319
|
-
if (sidebarForcedCollapsed.value) return;
|
|
320
|
-
sidebarCollapsed.value = !sidebarCollapsed.value;
|
|
321
|
-
localStorage.setItem(LS_SIDEBAR, String(sidebarCollapsed.value));
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
export function toggleFolder(folderId) {
|
|
325
|
-
const key = folderId || 'unsorted';
|
|
326
|
-
const next = { ...foldersCollapsed.value, [key]: !foldersCollapsed.value[key] };
|
|
327
|
-
foldersCollapsed.value = next;
|
|
328
|
-
localStorage.setItem(LS_FOLDERS_COLLAPSED, JSON.stringify(next));
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
export function toggleCardFold(key) {
|
|
332
|
-
const next = { ...cardFolded.value, [key]: !cardFolded.value[key] };
|
|
333
|
-
cardFolded.value = next;
|
|
334
|
-
try { localStorage.setItem(`ccsm.fold.${key}`, next[key] ? '1' : '0'); } catch {}
|
|
335
|
-
}
|
|
1
|
+
// All shared reactive state. Importing a signal anywhere subscribes the
|
|
2
|
+
// reading component, so we never need a store / context wrapper.
|
|
3
|
+
|
|
4
|
+
import { signal, computed } from '@preact/signals';
|
|
5
|
+
|
|
6
|
+
// ── server-driven data ──────────────────────────────────────────
|
|
7
|
+
export const config = signal(null);
|
|
8
|
+
export const capabilities = signal({ webTerminal: false });
|
|
9
|
+
// `sessions` is the ccsm-persisted list (lib/persistedSessions). Every
|
|
10
|
+
// entry has { id, cliId, cwd, workspace, title, folderId, repos,
|
|
11
|
+
// createdAt, lastActiveAt, status, exitedAt, exitCode, pid }.
|
|
12
|
+
export const sessions = signal([]);
|
|
13
|
+
export const folders = signal([]); // [{id,name,order,createdAt}]
|
|
14
|
+
export const workspaces = signal([]);
|
|
15
|
+
export const serverHealth = signal({ state: 'connecting' });
|
|
16
|
+
// Flips true the first time we successfully reach the backend in this
|
|
17
|
+
// frontend session. Gates UI (HealthOverlay) so it doesn't pop on the
|
|
18
|
+
// very first boot probe while the page is still wiring up.
|
|
19
|
+
export const hasBootedOnline = signal(false);
|
|
20
|
+
// Set true the moment the user clicks "Restart backend" — the
|
|
21
|
+
// RestartOverlay reads this signal and blocks the whole page until
|
|
22
|
+
// the next health poll returns a fresh PID. Cleared by the overlay
|
|
23
|
+
// itself on reconnect. Kept here (not in ConfigurePage local state)
|
|
24
|
+
// so a stale tab on another page can't miss the in-flight restart.
|
|
25
|
+
export const restartInFlight = signal(null); // { startedAt, prevPid } | null
|
|
26
|
+
|
|
27
|
+
// ── ui state (persisted in localStorage where noted) ───────────
|
|
28
|
+
export const activeTab = signal('sessions');
|
|
29
|
+
export const activeSessionId = signal(null); // the session currently rendered in the right pane
|
|
30
|
+
export const sidebarCollapsed = signal(false);
|
|
31
|
+
// True when viewport is narrow enough that the sidebar is force-collapsed
|
|
32
|
+
// by the responsive layout — the toggle button hides in that case so the
|
|
33
|
+
// user can't try (and fail) to expand it.
|
|
34
|
+
export const sidebarForcedCollapsed = signal(false);
|
|
35
|
+
// True on phone-sized viewports (≤ 640px). The sidebar then hides
|
|
36
|
+
// entirely; a FAB at bottom-left opens a full-screen drawer.
|
|
37
|
+
export const isMobile = signal(false);
|
|
38
|
+
// Mobile drawer visibility — toggled by the FAB / nav-item taps.
|
|
39
|
+
export const mobileDrawerOpen = signal(false);
|
|
40
|
+
export const sidebarWidth = signal(232); // px when expanded, persisted in localStorage
|
|
41
|
+
export const accentColor = signal('#2f6fa3'); // user-chosen brand accent, persisted
|
|
42
|
+
export const themeMode = signal('system'); // 'light' | 'dark' | 'system', persisted
|
|
43
|
+
// Per-folder collapse state in the sidebar tree. Stored as a plain object
|
|
44
|
+
// {folderId: true} (true = collapsed). Key 'unsorted' covers the implicit
|
|
45
|
+
// Unsorted bucket.
|
|
46
|
+
export const foldersCollapsed = signal({});
|
|
47
|
+
export const configDirty = signal(false);
|
|
48
|
+
// Per-card fold state on pages that use the <Card> component. The card
|
|
49
|
+
// just toggles a key here; persistence is best-effort via localStorage
|
|
50
|
+
// under `ccsm.fold.<key>` (set by toggleCardFold).
|
|
51
|
+
export const cardFolded = signal({});
|
|
52
|
+
export const clockTick = signal(Date.now()); // re-ticked each second so fmtAgo refreshes
|
|
53
|
+
export const lastRefreshAt = signal(0); // ms timestamp of last successful refreshAll()
|
|
54
|
+
export const installPrompt = signal(null); // captured beforeinstallprompt event (PWA install)
|
|
55
|
+
export const isInstalledPwa = signal(false); // running inside an installed PWA window
|
|
56
|
+
|
|
57
|
+
// ── derived ─────────────────────────────────────────────────────
|
|
58
|
+
// Group sessions by folder, with a synthetic "unsorted" bucket for those
|
|
59
|
+
// without a folderId. Folders define the rendering order; sessions
|
|
60
|
+
// inside each are sorted by createdAt desc (stable — using lastActiveAt
|
|
61
|
+
// would make rows jump on resume).
|
|
62
|
+
//
|
|
63
|
+
// We pre-create a bucket per declared session.folderId even if the
|
|
64
|
+
// matching folder hasn't loaded yet — that way on first paint sessions
|
|
65
|
+
// don't all collapse into Unsorted and then snap back into their real
|
|
66
|
+
// folder a few ms later when /api/folders resolves.
|
|
67
|
+
// "Unsorted" is keyed as 'unsorted' (not null) so it can be looked up
|
|
68
|
+
// alongside real folders by Sidebar/keybindings iterating folders.value
|
|
69
|
+
// — backend exposes a synthetic folder with id='unsorted' that's always
|
|
70
|
+
// present, drag-reorderable like real folders.
|
|
71
|
+
export const UNSORTED_KEY = 'unsorted';
|
|
72
|
+
export const sessionsByFolder = computed(() => {
|
|
73
|
+
const groups = new Map();
|
|
74
|
+
groups.set(UNSORTED_KEY, []);
|
|
75
|
+
for (const f of folders.value) groups.set(f.id, []);
|
|
76
|
+
for (const s of sessions.value) {
|
|
77
|
+
const key = s.folderId || UNSORTED_KEY;
|
|
78
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
79
|
+
groups.get(key).push(s);
|
|
80
|
+
}
|
|
81
|
+
for (const list of groups.values()) {
|
|
82
|
+
// Stable sort: explicit `order` field first (set by user drag), then
|
|
83
|
+
// createdAt desc as fallback. Sessions without `order` fall to the
|
|
84
|
+
// top (newer-first) which is the legacy behavior.
|
|
85
|
+
list.sort((a, b) => {
|
|
86
|
+
const oa = typeof a.order === 'number' ? a.order : null;
|
|
87
|
+
const ob = typeof b.order === 'number' ? b.order : null;
|
|
88
|
+
if (oa !== null && ob !== null) return oa - ob;
|
|
89
|
+
if (oa !== null) return -1;
|
|
90
|
+
if (ob !== null) return 1;
|
|
91
|
+
return (b.createdAt || 0) - (a.createdAt || 0);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return groups;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
export const TAB_HEADINGS = {
|
|
98
|
+
sessions: { title: 'Sessions', subtitle: 'Sessions you started in ccsm.' },
|
|
99
|
+
launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace.' },
|
|
100
|
+
configure: { title: 'Configure', subtitle: 'Persisted to ~/.ccsm/config.json.' },
|
|
101
|
+
remote: { title: 'Remote', subtitle: 'Expose this backend to another device via tunnel + token.' },
|
|
102
|
+
about: { title: 'About', subtitle: 'ccsm — Claude CLI Sessions Manager.' },
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// ── persistence helpers (localStorage) ──────────────────────────
|
|
106
|
+
const LS_SIDEBAR = 'ccsm.sidebar-collapsed';
|
|
107
|
+
const LS_SIDEBAR_W = 'ccsm.sidebar-width';
|
|
108
|
+
const LS_ACCENT = 'ccsm.accent';
|
|
109
|
+
const LS_THEME = 'ccsm.theme';
|
|
110
|
+
const LS_FOLDERS_COLLAPSED = 'ccsm.folders-collapsed';
|
|
111
|
+
// Last-known sidebar tree, rehydrated on boot to keep the first paint
|
|
112
|
+
// stable. The next refreshAll() overwrites these from the server, so
|
|
113
|
+
// stale entries self-heal within ~5s without any explicit invalidation.
|
|
114
|
+
const LS_FOLDERS_CACHE = 'ccsm.folders-cache';
|
|
115
|
+
const LS_SESSIONS_CACHE = 'ccsm.sessions-cache';
|
|
116
|
+
|
|
117
|
+
export const SIDEBAR_MIN = 180;
|
|
118
|
+
export const SIDEBAR_MAX = 400;
|
|
119
|
+
export const SIDEBAR_DEFAULT = 232;
|
|
120
|
+
export const ACCENT_DEFAULT = '#2f6fa3';
|
|
121
|
+
|
|
122
|
+
export function loadPersisted() {
|
|
123
|
+
sidebarCollapsed.value = localStorage.getItem(LS_SIDEBAR) === 'true';
|
|
124
|
+
const w = Number(localStorage.getItem(LS_SIDEBAR_W));
|
|
125
|
+
if (Number.isFinite(w) && w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) {
|
|
126
|
+
sidebarWidth.value = w;
|
|
127
|
+
}
|
|
128
|
+
applySidebarWidthCssVar();
|
|
129
|
+
const a = localStorage.getItem(LS_ACCENT);
|
|
130
|
+
if (isHexColor(a)) accentColor.value = a;
|
|
131
|
+
const t = localStorage.getItem(LS_THEME);
|
|
132
|
+
if (t === 'light' || t === 'dark' || t === 'system') themeMode.value = t;
|
|
133
|
+
applyTheme();
|
|
134
|
+
try {
|
|
135
|
+
const raw = localStorage.getItem(LS_FOLDERS_COLLAPSED);
|
|
136
|
+
if (raw) {
|
|
137
|
+
const parsed = JSON.parse(raw);
|
|
138
|
+
if (parsed && typeof parsed === 'object') foldersCollapsed.value = parsed;
|
|
139
|
+
}
|
|
140
|
+
} catch {}
|
|
141
|
+
// Rehydrate the sidebar tree from the last seen server state so
|
|
142
|
+
// the first paint matches the user's last view. refreshAll() arrives
|
|
143
|
+
// ~50–500ms later and overwrites with fresh data.
|
|
144
|
+
try {
|
|
145
|
+
const raw = localStorage.getItem(LS_FOLDERS_CACHE);
|
|
146
|
+
if (raw) {
|
|
147
|
+
const parsed = JSON.parse(raw);
|
|
148
|
+
if (Array.isArray(parsed)) folders.value = parsed;
|
|
149
|
+
}
|
|
150
|
+
} catch {}
|
|
151
|
+
try {
|
|
152
|
+
const raw = localStorage.getItem(LS_SESSIONS_CACHE);
|
|
153
|
+
if (raw) {
|
|
154
|
+
const parsed = JSON.parse(raw);
|
|
155
|
+
if (Array.isArray(parsed)) sessions.value = parsed;
|
|
156
|
+
}
|
|
157
|
+
} catch {}
|
|
158
|
+
const hash = location.hash.slice(1);
|
|
159
|
+
if (TAB_HEADINGS[hash]) activeTab.value = hash;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function applySidebarWidthCssVar() {
|
|
163
|
+
document.documentElement.style.setProperty('--sidebar-w', `${sidebarWidth.value}px`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function setSidebarWidth(px) {
|
|
167
|
+
const clamped = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, Math.round(px)));
|
|
168
|
+
sidebarWidth.value = clamped;
|
|
169
|
+
applySidebarWidthCssVar();
|
|
170
|
+
localStorage.setItem(LS_SIDEBAR_W, String(clamped));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── theme (accent + light/dark) ─────────────────────────────────
|
|
174
|
+
function isHexColor(s) {
|
|
175
|
+
return typeof s === 'string' && /^#[0-9a-fA-F]{6}$/.test(s);
|
|
176
|
+
}
|
|
177
|
+
function hexToRgb(hex) {
|
|
178
|
+
const n = parseInt(hex.slice(1), 16);
|
|
179
|
+
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
|
180
|
+
}
|
|
181
|
+
function rgbToHex({ r, g, b }) {
|
|
182
|
+
const h = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
|
|
183
|
+
return `#${h(r)}${h(g)}${h(b)}`;
|
|
184
|
+
}
|
|
185
|
+
// Linear blend c1→c2 by t∈[0,1]. t=0 yields c1, t=1 yields c2.
|
|
186
|
+
function lerp(c1, c2, t) {
|
|
187
|
+
return { r: c1.r + (c2.r - c1.r) * t, g: c1.g + (c2.g - c1.g) * t, b: c1.b + (c2.b - c1.b) * t };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Anchor colors the palette is derived from. Light mode mixes the accent
|
|
191
|
+
// toward WHITE for surfaces and keeps warm-dark ink; dark mode mixes the
|
|
192
|
+
// accent toward DARK for surfaces and uses warm-light ink — same accent,
|
|
193
|
+
// inverted ground. Keep these in sync with the pre-paint script in
|
|
194
|
+
// public/index.html (it re-derives the same values to avoid a FOUC).
|
|
195
|
+
const WHITE = { r: 255, g: 255, b: 255 };
|
|
196
|
+
const DARK_BASE = { r: 0x18, g: 0x16, b: 0x12 }; // #181612 warm near-black
|
|
197
|
+
const LIGHT_INK = { r: 0xec, g: 0xe7, b: 0xda }; // #ece7da warm light text
|
|
198
|
+
|
|
199
|
+
// True when the effective theme is dark. 'system' consults the OS.
|
|
200
|
+
function resolveDark(mode) {
|
|
201
|
+
if (mode === 'dark') return true;
|
|
202
|
+
if (mode === 'light') return false;
|
|
203
|
+
return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function applyAccentCssVars() {
|
|
207
|
+
const base = accentColor.value;
|
|
208
|
+
const A = hexToRgb(base);
|
|
209
|
+
const dark = resolveDark(themeMode.value);
|
|
210
|
+
const root = document.documentElement.style;
|
|
211
|
+
let vars;
|
|
212
|
+
if (dark) {
|
|
213
|
+
const bg = lerp(DARK_BASE, A, 0.06); // dark ground, faint accent tint
|
|
214
|
+
const lift = (t) => rgbToHex(lerp(bg, LIGHT_INK, t)); // raise toward light
|
|
215
|
+
vars = {
|
|
216
|
+
'--accent': base,
|
|
217
|
+
'--accent-deep': rgbToHex(lerp(A, LIGHT_INK, 0.18)), // brighter on dark
|
|
218
|
+
'--accent-soft': `rgba(${A.r}, ${A.g}, ${A.b}, 0.18)`,
|
|
219
|
+
'--accent-softer': `rgba(${A.r}, ${A.g}, ${A.b}, 0.07)`,
|
|
220
|
+
'--bg': rgbToHex(bg),
|
|
221
|
+
'--bg-elev': lift(0.05),
|
|
222
|
+
'--sidebar-bg': rgbToHex(bg),
|
|
223
|
+
'--sidebar-hover': lift(0.09),
|
|
224
|
+
'--sidebar-active': lift(0.15),
|
|
225
|
+
'--border': lift(0.14),
|
|
226
|
+
'--border-soft': lift(0.09),
|
|
227
|
+
'--border-strong': lift(0.24),
|
|
228
|
+
'--ui-bg': lift(0.05),
|
|
229
|
+
'--ui-border': lift(0.16),
|
|
230
|
+
'--ui-border-soft': lift(0.10),
|
|
231
|
+
'--ink': rgbToHex(LIGHT_INK),
|
|
232
|
+
'--ink-mid': rgbToHex(lerp(LIGHT_INK, DARK_BASE, 0.28)),
|
|
233
|
+
'--ink-muted': rgbToHex(lerp(LIGHT_INK, DARK_BASE, 0.45)),
|
|
234
|
+
'--ink-faint': rgbToHex(lerp(LIGHT_INK, DARK_BASE, 0.60)),
|
|
235
|
+
};
|
|
236
|
+
} else {
|
|
237
|
+
const mix = (t) => rgbToHex(lerp(WHITE, A, t)); // light ground, accent tint
|
|
238
|
+
vars = {
|
|
239
|
+
'--accent': base,
|
|
240
|
+
'--accent-deep': rgbToHex(lerp(A, { r: 0, g: 0, b: 0 }, 0.2)),
|
|
241
|
+
'--accent-soft': `rgba(${A.r}, ${A.g}, ${A.b}, 0.10)`,
|
|
242
|
+
'--accent-softer': `rgba(${A.r}, ${A.g}, ${A.b}, 0.04)`,
|
|
243
|
+
'--bg': mix(0.04),
|
|
244
|
+
'--bg-elev': '#ffffff',
|
|
245
|
+
'--sidebar-bg': mix(0.04),
|
|
246
|
+
'--sidebar-hover': mix(0.10),
|
|
247
|
+
'--sidebar-active': mix(0.15),
|
|
248
|
+
'--border': mix(0.15),
|
|
249
|
+
'--border-soft': mix(0.12),
|
|
250
|
+
'--border-strong': mix(0.25),
|
|
251
|
+
'--ui-bg': mix(0.10),
|
|
252
|
+
'--ui-border': '#d8d4c6', // theme-independent neutral
|
|
253
|
+
'--ui-border-soft': '#e6e2d4', // theme-independent neutral
|
|
254
|
+
'--ink': '#1a1815',
|
|
255
|
+
'--ink-mid': '#534e44',
|
|
256
|
+
'--ink-muted': '#8a8475',
|
|
257
|
+
'--ink-faint': '#b5af9d',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
for (const [k, v] of Object.entries(vars)) root.setProperty(k, v);
|
|
261
|
+
const meta = document.querySelector('meta[name="theme-color"]');
|
|
262
|
+
if (meta) meta.setAttribute('content', vars['--bg']);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Set data-theme on <html> (drives the [data-theme="dark"] CSS overrides)
|
|
266
|
+
// and re-derive the accent-tinted palette for the resolved theme.
|
|
267
|
+
function applyTheme() {
|
|
268
|
+
const dark = resolveDark(themeMode.value);
|
|
269
|
+
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
|
270
|
+
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
|
271
|
+
applyAccentCssVars();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// React to OS theme changes while in 'system' mode.
|
|
275
|
+
if (window.matchMedia) {
|
|
276
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
277
|
+
if (themeMode.value === 'system') applyTheme();
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Resolved theme for non-CSS consumers (e.g. the xterm canvas, which is
|
|
282
|
+
// painted from a JS color object, not CSS vars).
|
|
283
|
+
export function isDarkTheme() {
|
|
284
|
+
return resolveDark(themeMode.value);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function setThemeMode(mode) {
|
|
288
|
+
if (mode !== 'light' && mode !== 'dark' && mode !== 'system') return;
|
|
289
|
+
themeMode.value = mode;
|
|
290
|
+
applyTheme();
|
|
291
|
+
localStorage.setItem(LS_THEME, mode);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function setAccentColor(hex) {
|
|
295
|
+
if (!isHexColor(hex)) return;
|
|
296
|
+
accentColor.value = hex;
|
|
297
|
+
applyAccentCssVars();
|
|
298
|
+
localStorage.setItem(LS_ACCENT, hex);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── actions ─────────────────────────────────────────────────────
|
|
302
|
+
export function selectTab(name) {
|
|
303
|
+
if (!TAB_HEADINGS[name]) name = 'sessions';
|
|
304
|
+
activeTab.value = name;
|
|
305
|
+
if (location.hash !== `#${name}`) window.history.replaceState(null, '', `#${name}`);
|
|
306
|
+
// Tapping a nav item on mobile is also a "close the drawer" gesture
|
|
307
|
+
// — the user got what they came for, no need to keep the overlay up.
|
|
308
|
+
if (mobileDrawerOpen.value) mobileDrawerOpen.value = false;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function selectSession(id) {
|
|
312
|
+
activeSessionId.value = id;
|
|
313
|
+
activeTab.value = 'sessions';
|
|
314
|
+
if (location.hash !== '#sessions') window.history.replaceState(null, '', '#sessions');
|
|
315
|
+
if (mobileDrawerOpen.value) mobileDrawerOpen.value = false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function toggleSidebar() {
|
|
319
|
+
if (sidebarForcedCollapsed.value) return;
|
|
320
|
+
sidebarCollapsed.value = !sidebarCollapsed.value;
|
|
321
|
+
localStorage.setItem(LS_SIDEBAR, String(sidebarCollapsed.value));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function toggleFolder(folderId) {
|
|
325
|
+
const key = folderId || 'unsorted';
|
|
326
|
+
const next = { ...foldersCollapsed.value, [key]: !foldersCollapsed.value[key] };
|
|
327
|
+
foldersCollapsed.value = next;
|
|
328
|
+
localStorage.setItem(LS_FOLDERS_COLLAPSED, JSON.stringify(next));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function toggleCardFold(key) {
|
|
332
|
+
const next = { ...cardFolded.value, [key]: !cardFolded.value[key] };
|
|
333
|
+
cardFolded.value = next;
|
|
334
|
+
try { localStorage.setItem(`ccsm.fold.${key}`, next[key] ? '1' : '0'); } catch {}
|
|
335
|
+
}
|