@bakapiano/ccsm 0.22.2 → 0.22.4

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 (60) hide show
  1. package/CLAUDE.md +538 -538
  2. package/README.md +189 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +139 -139
  5. package/lib/codexSeed.js +183 -183
  6. package/lib/config.js +274 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/localCliSessions.js +519 -519
  10. package/lib/persistedSessions.js +129 -129
  11. package/lib/tunnel.js +621 -621
  12. package/lib/webTerminal.js +233 -231
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +176 -176
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +592 -592
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2725 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +371 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +187 -15
  42. package/public/js/components/TerminalResizeDebouncer.js +126 -0
  43. package/public/js/components/XtermTerminal.js +148 -14
  44. package/public/js/components/useDragSort.js +67 -67
  45. package/public/js/dialog.js +67 -67
  46. package/public/js/icons.js +212 -212
  47. package/public/js/main.js +296 -296
  48. package/public/js/pages/AboutPage.js +90 -90
  49. package/public/js/pages/ConfigurePage.js +713 -713
  50. package/public/js/pages/LaunchPage.js +421 -421
  51. package/public/js/pages/RemotePage.js +743 -743
  52. package/public/js/pages/SessionsPage.js +100 -100
  53. package/public/js/state.js +335 -335
  54. package/public/manifest.webmanifest +25 -0
  55. package/public/setup/index.html +567 -0
  56. package/scripts/dev.js +149 -149
  57. package/scripts/install.js +153 -153
  58. package/scripts/restart-helper.js +96 -96
  59. package/scripts/upgrade-helper.js +687 -687
  60. package/server.js +1807 -1807
@@ -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
+ }