@bakapiano/ccsm 0.14.0 → 0.15.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 (52) 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 +211 -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 +165 -165
  43. package/public/js/pages/ConfigurePage.js +505 -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 +91 -0
  50. package/server.js +1278 -1254
  51. package/lib/cliSessionWatcher.js +0 -275
  52. package/public/manifest.webmanifest +0 -15
@@ -1,231 +1,231 @@
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
-
17
- // ── ui state (persisted in localStorage where noted) ───────────
18
- export const activeTab = signal('sessions');
19
- export const activeSessionId = signal(null); // the session currently rendered in the right pane
20
- export const sidebarCollapsed = signal(false);
21
- // True when viewport is narrow enough that the sidebar is force-collapsed
22
- // by the responsive layout — the toggle button hides in that case so the
23
- // user can't try (and fail) to expand it.
24
- export const sidebarForcedCollapsed = signal(false);
25
- export const sidebarWidth = signal(232); // px when expanded, persisted in localStorage
26
- export const accentColor = signal('#2f6fa3'); // user-chosen brand accent, persisted
27
- // Per-folder collapse state in the sidebar tree. Stored as a plain object
28
- // {folderId: true} (true = collapsed). Key 'unsorted' covers the implicit
29
- // Unsorted bucket.
30
- export const foldersCollapsed = signal({});
31
- export const configDirty = signal(false);
32
- // Per-card fold state on pages that use the <Card> component. The card
33
- // just toggles a key here; persistence is best-effort via localStorage
34
- // under `ccsm.fold.<key>` (set by toggleCardFold).
35
- export const cardFolded = signal({});
36
- export const clockTick = signal(Date.now()); // re-ticked each second so fmtAgo refreshes
37
- export const lastRefreshAt = signal(0); // ms timestamp of last successful refreshAll()
38
- export const installPrompt = signal(null); // captured beforeinstallprompt event (PWA install)
39
- export const isInstalledPwa = signal(false); // running inside an installed PWA window
40
-
41
- // ── derived ─────────────────────────────────────────────────────
42
- // Group sessions by folder, with a synthetic "unsorted" bucket for those
43
- // without a folderId. Folders define the rendering order; sessions
44
- // inside each are sorted by createdAt desc (stable — using lastActiveAt
45
- // would make rows jump on resume).
46
- //
47
- // We pre-create a bucket per declared session.folderId even if the
48
- // matching folder hasn't loaded yet — that way on first paint sessions
49
- // don't all collapse into Unsorted and then snap back into their real
50
- // folder a few ms later when /api/folders resolves.
51
- export const sessionsByFolder = computed(() => {
52
- const groups = new Map();
53
- groups.set(null, []);
54
- for (const f of folders.value) groups.set(f.id, []);
55
- for (const s of sessions.value) {
56
- const key = s.folderId || null;
57
- if (!groups.has(key)) groups.set(key, []);
58
- groups.get(key).push(s);
59
- }
60
- for (const list of groups.values()) {
61
- list.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
62
- }
63
- return groups;
64
- });
65
-
66
- export const TAB_HEADINGS = {
67
- sessions: { title: 'Sessions', subtitle: 'Sessions you started in ccsm.' },
68
- launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace.' },
69
- configure: { title: 'Configure', subtitle: 'Persisted to ~/.ccsm/config.json.' },
70
- about: { title: 'About', subtitle: 'ccsm — Claude CLI Sessions Manager.' },
71
- };
72
-
73
- // ── persistence helpers (localStorage) ──────────────────────────
74
- const LS_SIDEBAR = 'ccsm.sidebar-collapsed';
75
- const LS_SIDEBAR_W = 'ccsm.sidebar-width';
76
- const LS_ACCENT = 'ccsm.accent';
77
- const LS_FOLDERS_COLLAPSED = 'ccsm.folders-collapsed';
78
- // Last-known sidebar tree, rehydrated on boot to keep the first paint
79
- // stable. The next refreshAll() overwrites these from the server, so
80
- // stale entries self-heal within ~5s without any explicit invalidation.
81
- const LS_FOLDERS_CACHE = 'ccsm.folders-cache';
82
- const LS_SESSIONS_CACHE = 'ccsm.sessions-cache';
83
-
84
- export const SIDEBAR_MIN = 180;
85
- export const SIDEBAR_MAX = 400;
86
- export const SIDEBAR_DEFAULT = 232;
87
- export const ACCENT_DEFAULT = '#2f6fa3';
88
-
89
- export function loadPersisted() {
90
- sidebarCollapsed.value = localStorage.getItem(LS_SIDEBAR) === 'true';
91
- const w = Number(localStorage.getItem(LS_SIDEBAR_W));
92
- if (Number.isFinite(w) && w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) {
93
- sidebarWidth.value = w;
94
- }
95
- applySidebarWidthCssVar();
96
- const a = localStorage.getItem(LS_ACCENT);
97
- if (isHexColor(a)) accentColor.value = a;
98
- applyAccentCssVars();
99
- try {
100
- const raw = localStorage.getItem(LS_FOLDERS_COLLAPSED);
101
- if (raw) {
102
- const parsed = JSON.parse(raw);
103
- if (parsed && typeof parsed === 'object') foldersCollapsed.value = parsed;
104
- }
105
- } catch {}
106
- // Rehydrate the sidebar tree from the last seen server state so
107
- // the first paint matches the user's last view. refreshAll() arrives
108
- // ~50–500ms later and overwrites with fresh data.
109
- try {
110
- const raw = localStorage.getItem(LS_FOLDERS_CACHE);
111
- if (raw) {
112
- const parsed = JSON.parse(raw);
113
- if (Array.isArray(parsed)) folders.value = parsed;
114
- }
115
- } catch {}
116
- try {
117
- const raw = localStorage.getItem(LS_SESSIONS_CACHE);
118
- if (raw) {
119
- const parsed = JSON.parse(raw);
120
- if (Array.isArray(parsed)) sessions.value = parsed;
121
- }
122
- } catch {}
123
- const hash = location.hash.slice(1);
124
- if (TAB_HEADINGS[hash]) activeTab.value = hash;
125
- }
126
-
127
- function applySidebarWidthCssVar() {
128
- document.documentElement.style.setProperty('--sidebar-w', `${sidebarWidth.value}px`);
129
- }
130
-
131
- export function setSidebarWidth(px) {
132
- const clamped = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, Math.round(px)));
133
- sidebarWidth.value = clamped;
134
- applySidebarWidthCssVar();
135
- localStorage.setItem(LS_SIDEBAR_W, String(clamped));
136
- }
137
-
138
- // ── theme accent ────────────────────────────────────────────────
139
- function isHexColor(s) {
140
- return typeof s === 'string' && /^#[0-9a-fA-F]{6}$/.test(s);
141
- }
142
- function hexToRgb(hex) {
143
- const n = parseInt(hex.slice(1), 16);
144
- return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
145
- }
146
- function rgbToHex({ r, g, b }) {
147
- const h = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
148
- return `#${h(r)}${h(g)}${h(b)}`;
149
- }
150
- function darken({ r, g, b }, amount) {
151
- return { r: r * (1 - amount), g: g * (1 - amount), b: b * (1 - amount) };
152
- }
153
- function mixWithWhite({ r, g, b }, t) {
154
- return { r: r * t + 255 * (1 - t), g: g * t + 255 * (1 - t), b: b * t + 255 * (1 - t) };
155
- }
156
- function applyAccentCssVars() {
157
- const base = accentColor.value;
158
- const rgb = hexToRgb(base);
159
- const deep = rgbToHex(darken(rgb, 0.2));
160
- const soft = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.10)`;
161
- const softer = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.04)`;
162
- const bg = rgbToHex(mixWithWhite(rgb, 0.04));
163
- const sidebarHover = rgbToHex(mixWithWhite(rgb, 0.10));
164
- const sidebarActive= rgbToHex(mixWithWhite(rgb, 0.15));
165
- const border = rgbToHex(mixWithWhite(rgb, 0.15));
166
- const borderSoft = rgbToHex(mixWithWhite(rgb, 0.12));
167
- const borderStrong = rgbToHex(mixWithWhite(rgb, 0.25));
168
- // UI chrome (sidebar bg, dividers, footer strip) — themed too but
169
- // visibly darker than the main bg so sidebar/main read as distinct.
170
- // Border colors stay deliberately desaturated so dividers don't shout
171
- // the brand color back at the user.
172
- const uiBg = rgbToHex(mixWithWhite(rgb, 0.10));
173
- const uiBorder = '#d8d4c6'; // theme-independent neutral
174
- const uiBorderSoft = '#e6e2d4'; // theme-independent neutral
175
- const root = document.documentElement.style;
176
- root.setProperty('--accent', base);
177
- root.setProperty('--accent-deep', deep);
178
- root.setProperty('--accent-soft', soft);
179
- root.setProperty('--accent-softer', softer);
180
- root.setProperty('--bg', bg);
181
- root.setProperty('--sidebar-bg', bg);
182
- root.setProperty('--sidebar-hover', sidebarHover);
183
- root.setProperty('--sidebar-active', sidebarActive);
184
- root.setProperty('--border', border);
185
- root.setProperty('--border-soft', borderSoft);
186
- root.setProperty('--border-strong', borderStrong);
187
- root.setProperty('--ui-bg', uiBg);
188
- root.setProperty('--ui-border', uiBorder);
189
- root.setProperty('--ui-border-soft', uiBorderSoft);
190
- const meta = document.querySelector('meta[name="theme-color"]');
191
- if (meta) meta.setAttribute('content', bg);
192
- }
193
-
194
- export function setAccentColor(hex) {
195
- if (!isHexColor(hex)) return;
196
- accentColor.value = hex;
197
- applyAccentCssVars();
198
- localStorage.setItem(LS_ACCENT, hex);
199
- }
200
-
201
- // ── actions ─────────────────────────────────────────────────────
202
- export function selectTab(name) {
203
- if (!TAB_HEADINGS[name]) name = 'sessions';
204
- activeTab.value = name;
205
- if (location.hash !== `#${name}`) window.history.replaceState(null, '', `#${name}`);
206
- }
207
-
208
- export function selectSession(id) {
209
- activeSessionId.value = id;
210
- activeTab.value = 'sessions';
211
- if (location.hash !== '#sessions') window.history.replaceState(null, '', '#sessions');
212
- }
213
-
214
- export function toggleSidebar() {
215
- if (sidebarForcedCollapsed.value) return;
216
- sidebarCollapsed.value = !sidebarCollapsed.value;
217
- localStorage.setItem(LS_SIDEBAR, String(sidebarCollapsed.value));
218
- }
219
-
220
- export function toggleFolder(folderId) {
221
- const key = folderId || 'unsorted';
222
- const next = { ...foldersCollapsed.value, [key]: !foldersCollapsed.value[key] };
223
- foldersCollapsed.value = next;
224
- localStorage.setItem(LS_FOLDERS_COLLAPSED, JSON.stringify(next));
225
- }
226
-
227
- export function toggleCardFold(key) {
228
- const next = { ...cardFolded.value, [key]: !cardFolded.value[key] };
229
- cardFolded.value = next;
230
- try { localStorage.setItem(`ccsm.fold.${key}`, next[key] ? '1' : '0'); } catch {}
231
- }
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
+
17
+ // ── ui state (persisted in localStorage where noted) ───────────
18
+ export const activeTab = signal('sessions');
19
+ export const activeSessionId = signal(null); // the session currently rendered in the right pane
20
+ export const sidebarCollapsed = signal(false);
21
+ // True when viewport is narrow enough that the sidebar is force-collapsed
22
+ // by the responsive layout — the toggle button hides in that case so the
23
+ // user can't try (and fail) to expand it.
24
+ export const sidebarForcedCollapsed = signal(false);
25
+ export const sidebarWidth = signal(232); // px when expanded, persisted in localStorage
26
+ export const accentColor = signal('#2f6fa3'); // user-chosen brand accent, persisted
27
+ // Per-folder collapse state in the sidebar tree. Stored as a plain object
28
+ // {folderId: true} (true = collapsed). Key 'unsorted' covers the implicit
29
+ // Unsorted bucket.
30
+ export const foldersCollapsed = signal({});
31
+ export const configDirty = signal(false);
32
+ // Per-card fold state on pages that use the <Card> component. The card
33
+ // just toggles a key here; persistence is best-effort via localStorage
34
+ // under `ccsm.fold.<key>` (set by toggleCardFold).
35
+ export const cardFolded = signal({});
36
+ export const clockTick = signal(Date.now()); // re-ticked each second so fmtAgo refreshes
37
+ export const lastRefreshAt = signal(0); // ms timestamp of last successful refreshAll()
38
+ export const installPrompt = signal(null); // captured beforeinstallprompt event (PWA install)
39
+ export const isInstalledPwa = signal(false); // running inside an installed PWA window
40
+
41
+ // ── derived ─────────────────────────────────────────────────────
42
+ // Group sessions by folder, with a synthetic "unsorted" bucket for those
43
+ // without a folderId. Folders define the rendering order; sessions
44
+ // inside each are sorted by createdAt desc (stable — using lastActiveAt
45
+ // would make rows jump on resume).
46
+ //
47
+ // We pre-create a bucket per declared session.folderId even if the
48
+ // matching folder hasn't loaded yet — that way on first paint sessions
49
+ // don't all collapse into Unsorted and then snap back into their real
50
+ // folder a few ms later when /api/folders resolves.
51
+ export const sessionsByFolder = computed(() => {
52
+ const groups = new Map();
53
+ groups.set(null, []);
54
+ for (const f of folders.value) groups.set(f.id, []);
55
+ for (const s of sessions.value) {
56
+ const key = s.folderId || null;
57
+ if (!groups.has(key)) groups.set(key, []);
58
+ groups.get(key).push(s);
59
+ }
60
+ for (const list of groups.values()) {
61
+ list.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
62
+ }
63
+ return groups;
64
+ });
65
+
66
+ export const TAB_HEADINGS = {
67
+ sessions: { title: 'Sessions', subtitle: 'Sessions you started in ccsm.' },
68
+ launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace.' },
69
+ configure: { title: 'Configure', subtitle: 'Persisted to ~/.ccsm/config.json.' },
70
+ about: { title: 'About', subtitle: 'ccsm — Claude CLI Sessions Manager.' },
71
+ };
72
+
73
+ // ── persistence helpers (localStorage) ──────────────────────────
74
+ const LS_SIDEBAR = 'ccsm.sidebar-collapsed';
75
+ const LS_SIDEBAR_W = 'ccsm.sidebar-width';
76
+ const LS_ACCENT = 'ccsm.accent';
77
+ const LS_FOLDERS_COLLAPSED = 'ccsm.folders-collapsed';
78
+ // Last-known sidebar tree, rehydrated on boot to keep the first paint
79
+ // stable. The next refreshAll() overwrites these from the server, so
80
+ // stale entries self-heal within ~5s without any explicit invalidation.
81
+ const LS_FOLDERS_CACHE = 'ccsm.folders-cache';
82
+ const LS_SESSIONS_CACHE = 'ccsm.sessions-cache';
83
+
84
+ export const SIDEBAR_MIN = 180;
85
+ export const SIDEBAR_MAX = 400;
86
+ export const SIDEBAR_DEFAULT = 232;
87
+ export const ACCENT_DEFAULT = '#2f6fa3';
88
+
89
+ export function loadPersisted() {
90
+ sidebarCollapsed.value = localStorage.getItem(LS_SIDEBAR) === 'true';
91
+ const w = Number(localStorage.getItem(LS_SIDEBAR_W));
92
+ if (Number.isFinite(w) && w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) {
93
+ sidebarWidth.value = w;
94
+ }
95
+ applySidebarWidthCssVar();
96
+ const a = localStorage.getItem(LS_ACCENT);
97
+ if (isHexColor(a)) accentColor.value = a;
98
+ applyAccentCssVars();
99
+ try {
100
+ const raw = localStorage.getItem(LS_FOLDERS_COLLAPSED);
101
+ if (raw) {
102
+ const parsed = JSON.parse(raw);
103
+ if (parsed && typeof parsed === 'object') foldersCollapsed.value = parsed;
104
+ }
105
+ } catch {}
106
+ // Rehydrate the sidebar tree from the last seen server state so
107
+ // the first paint matches the user's last view. refreshAll() arrives
108
+ // ~50–500ms later and overwrites with fresh data.
109
+ try {
110
+ const raw = localStorage.getItem(LS_FOLDERS_CACHE);
111
+ if (raw) {
112
+ const parsed = JSON.parse(raw);
113
+ if (Array.isArray(parsed)) folders.value = parsed;
114
+ }
115
+ } catch {}
116
+ try {
117
+ const raw = localStorage.getItem(LS_SESSIONS_CACHE);
118
+ if (raw) {
119
+ const parsed = JSON.parse(raw);
120
+ if (Array.isArray(parsed)) sessions.value = parsed;
121
+ }
122
+ } catch {}
123
+ const hash = location.hash.slice(1);
124
+ if (TAB_HEADINGS[hash]) activeTab.value = hash;
125
+ }
126
+
127
+ function applySidebarWidthCssVar() {
128
+ document.documentElement.style.setProperty('--sidebar-w', `${sidebarWidth.value}px`);
129
+ }
130
+
131
+ export function setSidebarWidth(px) {
132
+ const clamped = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, Math.round(px)));
133
+ sidebarWidth.value = clamped;
134
+ applySidebarWidthCssVar();
135
+ localStorage.setItem(LS_SIDEBAR_W, String(clamped));
136
+ }
137
+
138
+ // ── theme accent ────────────────────────────────────────────────
139
+ function isHexColor(s) {
140
+ return typeof s === 'string' && /^#[0-9a-fA-F]{6}$/.test(s);
141
+ }
142
+ function hexToRgb(hex) {
143
+ const n = parseInt(hex.slice(1), 16);
144
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
145
+ }
146
+ function rgbToHex({ r, g, b }) {
147
+ const h = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
148
+ return `#${h(r)}${h(g)}${h(b)}`;
149
+ }
150
+ function darken({ r, g, b }, amount) {
151
+ return { r: r * (1 - amount), g: g * (1 - amount), b: b * (1 - amount) };
152
+ }
153
+ function mixWithWhite({ r, g, b }, t) {
154
+ return { r: r * t + 255 * (1 - t), g: g * t + 255 * (1 - t), b: b * t + 255 * (1 - t) };
155
+ }
156
+ function applyAccentCssVars() {
157
+ const base = accentColor.value;
158
+ const rgb = hexToRgb(base);
159
+ const deep = rgbToHex(darken(rgb, 0.2));
160
+ const soft = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.10)`;
161
+ const softer = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.04)`;
162
+ const bg = rgbToHex(mixWithWhite(rgb, 0.04));
163
+ const sidebarHover = rgbToHex(mixWithWhite(rgb, 0.10));
164
+ const sidebarActive= rgbToHex(mixWithWhite(rgb, 0.15));
165
+ const border = rgbToHex(mixWithWhite(rgb, 0.15));
166
+ const borderSoft = rgbToHex(mixWithWhite(rgb, 0.12));
167
+ const borderStrong = rgbToHex(mixWithWhite(rgb, 0.25));
168
+ // UI chrome (sidebar bg, dividers, footer strip) — themed too but
169
+ // visibly darker than the main bg so sidebar/main read as distinct.
170
+ // Border colors stay deliberately desaturated so dividers don't shout
171
+ // the brand color back at the user.
172
+ const uiBg = rgbToHex(mixWithWhite(rgb, 0.10));
173
+ const uiBorder = '#d8d4c6'; // theme-independent neutral
174
+ const uiBorderSoft = '#e6e2d4'; // theme-independent neutral
175
+ const root = document.documentElement.style;
176
+ root.setProperty('--accent', base);
177
+ root.setProperty('--accent-deep', deep);
178
+ root.setProperty('--accent-soft', soft);
179
+ root.setProperty('--accent-softer', softer);
180
+ root.setProperty('--bg', bg);
181
+ root.setProperty('--sidebar-bg', bg);
182
+ root.setProperty('--sidebar-hover', sidebarHover);
183
+ root.setProperty('--sidebar-active', sidebarActive);
184
+ root.setProperty('--border', border);
185
+ root.setProperty('--border-soft', borderSoft);
186
+ root.setProperty('--border-strong', borderStrong);
187
+ root.setProperty('--ui-bg', uiBg);
188
+ root.setProperty('--ui-border', uiBorder);
189
+ root.setProperty('--ui-border-soft', uiBorderSoft);
190
+ const meta = document.querySelector('meta[name="theme-color"]');
191
+ if (meta) meta.setAttribute('content', bg);
192
+ }
193
+
194
+ export function setAccentColor(hex) {
195
+ if (!isHexColor(hex)) return;
196
+ accentColor.value = hex;
197
+ applyAccentCssVars();
198
+ localStorage.setItem(LS_ACCENT, hex);
199
+ }
200
+
201
+ // ── actions ─────────────────────────────────────────────────────
202
+ export function selectTab(name) {
203
+ if (!TAB_HEADINGS[name]) name = 'sessions';
204
+ activeTab.value = name;
205
+ if (location.hash !== `#${name}`) window.history.replaceState(null, '', `#${name}`);
206
+ }
207
+
208
+ export function selectSession(id) {
209
+ activeSessionId.value = id;
210
+ activeTab.value = 'sessions';
211
+ if (location.hash !== '#sessions') window.history.replaceState(null, '', '#sessions');
212
+ }
213
+
214
+ export function toggleSidebar() {
215
+ if (sidebarForcedCollapsed.value) return;
216
+ sidebarCollapsed.value = !sidebarCollapsed.value;
217
+ localStorage.setItem(LS_SIDEBAR, String(sidebarCollapsed.value));
218
+ }
219
+
220
+ export function toggleFolder(folderId) {
221
+ const key = folderId || 'unsorted';
222
+ const next = { ...foldersCollapsed.value, [key]: !foldersCollapsed.value[key] };
223
+ foldersCollapsed.value = next;
224
+ localStorage.setItem(LS_FOLDERS_COLLAPSED, JSON.stringify(next));
225
+ }
226
+
227
+ export function toggleCardFold(key) {
228
+ const next = { ...cardFolded.value, [key]: !cardFolded.value[key] };
229
+ cardFolded.value = next;
230
+ try { localStorage.setItem(`ccsm.fold.${key}`, next[key] ? '1' : '0'); } catch {}
231
+ }
package/scripts/dev.js CHANGED
@@ -43,17 +43,50 @@ const env = {
43
43
  CCSM_HOME: DEV_HOME,
44
44
  CCSM_PORT: DEV_PORT,
45
45
  CCSM_NO_BROWSER: '1',
46
+ // Marks the running server as "launched by dev.js" so /api/restart can
47
+ // skip the production restart-helper path (which respawns the global
48
+ // `ccsm.cmd` and would replace our --watch checkout server). In dev
49
+ // mode the server just process.exit(0)s and this script respawns it.
50
+ CCSM_DEV: '1',
46
51
  };
47
52
 
48
53
  const serverPath = path.join(__dirname, '..', 'server.js');
49
- const child = spawn(process.execPath, ['--watch', serverPath], {
50
- env,
51
- stdio: 'inherit',
52
- });
53
-
54
- const forward = (sig) => () => child.kill(sig);
55
- process.on('SIGINT', forward('SIGINT'));
56
- process.on('SIGTERM', forward('SIGTERM'));
57
- child.on('exit', (code, signal) => {
58
- process.exit(signal ? 1 : (code ?? 0));
59
- });
54
+
55
+ let current = null;
56
+ let stopping = false;
57
+
58
+ function spawnServer() {
59
+ // Don't use `node --watch` here its restart-on-exit semantics are
60
+ // "wait for a file change after a clean exit", so calling
61
+ // process.exit(0) from /api/restart leaves --watch idling forever
62
+ // until the user touches a file. We do our own respawn-on-exit
63
+ // (below) which handles both the restart-by-exit path AND crashes,
64
+ // and the dev/api SSE endpoint still gives us frontend hot-reload
65
+ // without needing --watch for backend code (each restart pulls fresh
66
+ // require() cache anyway since this is a new process).
67
+ const child = spawn(process.execPath, [serverPath], {
68
+ env,
69
+ stdio: 'inherit',
70
+ });
71
+ child.on('exit', (code, signal) => {
72
+ if (stopping) {
73
+ process.exit(signal ? 1 : (code ?? 0));
74
+ return;
75
+ }
76
+ // Server asked to restart (POST /api/restart → gracefulShutdown +
77
+ // exit 0). Respawn — node --watch picks up any code changes that
78
+ // landed in the meantime. A small delay lets the port fully release.
79
+ console.log(`[dev] server exited (code=${code} signal=${signal || ''}) · respawning`);
80
+ setTimeout(() => { current = spawnServer(); }, 500);
81
+ });
82
+ return child;
83
+ }
84
+
85
+ const stop = (sig) => () => {
86
+ stopping = true;
87
+ if (current) current.kill(sig);
88
+ };
89
+ process.on('SIGINT', stop('SIGINT'));
90
+ process.on('SIGTERM', stop('SIGTERM'));
91
+
92
+ current = spawnServer();