@bakapiano/ccsm 0.10.3 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +475 -475
- package/README.md +190 -190
- package/bin/ccsm.js +194 -194
- package/lib/cliSessionWatcher.js +249 -249
- package/lib/config.js +185 -185
- package/lib/folders.js +96 -96
- package/lib/localCliSessions.js +489 -177
- package/lib/persistedSessions.js +134 -134
- package/lib/webTerminal.js +208 -208
- package/lib/workspace.js +230 -255
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +303 -303
- package/public/css/forms.css +405 -405
- package/public/css/layout.css +160 -160
- package/public/css/modal.css +190 -183
- package/public/css/responsive.css +10 -10
- package/public/css/sidebar.css +616 -601
- package/public/css/terminals.css +294 -294
- package/public/css/tokens.css +81 -79
- package/public/css/wco.css +98 -98
- package/public/css/widgets.css +1596 -1375
- package/public/index.html +105 -103
- package/public/js/api.js +272 -260
- package/public/js/components/AdoptModal.js +343 -171
- package/public/js/components/App.js +35 -35
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +105 -105
- package/public/js/components/Modal.js +51 -51
- package/public/js/components/OfflineBanner.js +93 -93
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/Sidebar.js +270 -270
- package/public/js/components/TerminalView.js +298 -298
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +177 -177
- package/public/js/main.js +140 -140
- package/public/js/pages/AboutPage.js +165 -165
- package/public/js/pages/ConfigurePage.js +475 -487
- package/public/js/pages/LaunchPage.js +369 -369
- package/public/js/pages/SessionsPage.js +97 -97
- package/public/js/state.js +231 -231
- package/public/manifest.webmanifest +15 -15
- package/scripts/install.js +137 -137
- package/server.js +1126 -1117
package/public/js/state.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "CCSM",
|
|
3
|
-
"short_name": "CCSM",
|
|
4
|
-
"version": "0.0.0-dev",
|
|
5
|
-
"description": "Single pane over every live claude session on this machine.",
|
|
6
|
-
"start_url": "./",
|
|
7
|
-
"scope": "./",
|
|
8
|
-
"display": "standalone",
|
|
9
|
-
"display_override": ["window-controls-overlay", "standalone"],
|
|
10
|
-
"background_color": "#
|
|
11
|
-
"theme_color": "#
|
|
12
|
-
"icons": [
|
|
13
|
-
{ "src": "favicon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any" }
|
|
14
|
-
]
|
|
15
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "CCSM",
|
|
3
|
+
"short_name": "CCSM",
|
|
4
|
+
"version": "0.0.0-dev",
|
|
5
|
+
"description": "Single pane over every live claude session on this machine.",
|
|
6
|
+
"start_url": "./",
|
|
7
|
+
"scope": "./",
|
|
8
|
+
"display": "standalone",
|
|
9
|
+
"display_override": ["window-controls-overlay", "standalone"],
|
|
10
|
+
"background_color": "#f6f8fa",
|
|
11
|
+
"theme_color": "#f6f8fa",
|
|
12
|
+
"icons": [
|
|
13
|
+
{ "src": "favicon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any" }
|
|
14
|
+
]
|
|
15
|
+
}
|