@bakapiano/ccsm 0.8.4 → 0.10.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 (70) hide show
  1. package/CLAUDE.md +222 -195
  2. package/README.md +78 -80
  3. package/bin/ccsm.js +1 -1
  4. package/lib/cliSessionWatcher.js +249 -0
  5. package/lib/config.js +101 -19
  6. package/lib/folders.js +96 -0
  7. package/lib/localCliSessions.js +177 -0
  8. package/lib/persistedSessions.js +134 -0
  9. package/lib/webTerminal.js +48 -13
  10. package/lib/workspace.js +26 -4
  11. package/package.json +4 -4
  12. package/public/assets/claude-color.svg +1 -0
  13. package/public/assets/codex-color.svg +1 -0
  14. package/public/assets/copilot-color.svg +1 -0
  15. package/public/css/base.css +22 -5
  16. package/public/css/cards.css +37 -3
  17. package/public/css/feedback.css +127 -43
  18. package/public/css/forms.css +133 -10
  19. package/public/css/layout.css +79 -26
  20. package/public/css/modal.css +40 -26
  21. package/public/css/responsive.css +2 -2
  22. package/public/css/sidebar.css +456 -20
  23. package/public/css/terminals.css +182 -0
  24. package/public/css/tokens.css +28 -12
  25. package/public/css/wco.css +47 -19
  26. package/public/css/widgets.css +1177 -6
  27. package/public/index.html +39 -4
  28. package/public/js/api.js +194 -37
  29. package/public/js/components/AdoptModal.js +171 -0
  30. package/public/js/components/App.js +1 -11
  31. package/public/js/components/DirectoryPicker.js +203 -0
  32. package/public/js/components/EntityFormModal.js +105 -0
  33. package/public/js/components/Modal.js +51 -0
  34. package/public/js/components/OfflineBanner.js +29 -23
  35. package/public/js/components/PageTitleBar.js +13 -0
  36. package/public/js/components/Picker.js +179 -0
  37. package/public/js/components/Popover.js +55 -0
  38. package/public/js/components/Sidebar.js +244 -26
  39. package/public/js/components/TerminalView.js +192 -2
  40. package/public/js/components/useDragSort.js +67 -0
  41. package/public/js/dialog.js +10 -2
  42. package/public/js/icons.js +66 -3
  43. package/public/js/main.js +54 -3
  44. package/public/js/pages/AboutPage.js +81 -1
  45. package/public/js/pages/ConfigurePage.js +452 -159
  46. package/public/js/pages/LaunchPage.js +328 -76
  47. package/public/js/pages/SessionsPage.js +91 -41
  48. package/public/js/state.js +179 -35
  49. package/public/manifest.webmanifest +2 -2
  50. package/scripts/install.js +1 -1
  51. package/server.js +763 -407
  52. package/lib/favorites.js +0 -51
  53. package/lib/focus.js +0 -369
  54. package/lib/labels.js +0 -29
  55. package/lib/launcher.js +0 -219
  56. package/lib/sessions.js +0 -272
  57. package/lib/snapshot.js +0 -141
  58. package/public/js/actions.js +0 -87
  59. package/public/js/components/Fab.js +0 -11
  60. package/public/js/components/FavoritesTable.js +0 -81
  61. package/public/js/components/Footer.js +0 -12
  62. package/public/js/components/NewSessionModal.js +0 -142
  63. package/public/js/components/PageHead.js +0 -33
  64. package/public/js/components/Pagination.js +0 -27
  65. package/public/js/components/RecentTable.js +0 -68
  66. package/public/js/components/SessionsTable.js +0 -71
  67. package/public/js/components/SnapshotPanel.js +0 -77
  68. package/public/js/components/TitleCell.js +0 -40
  69. package/public/js/components/WorkspacesGrid.js +0 -41
  70. package/public/js/pages/TerminalsPage.js +0 -74
@@ -5,69 +5,199 @@ import { signal, computed } from '@preact/signals';
5
5
 
6
6
  // ── server-driven data ──────────────────────────────────────────
7
7
  export const config = signal(null);
8
- export const terminals = signal([]);
9
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 }.
10
12
  export const sessions = signal([]);
11
- export const webTerminals = signal([]); // active in-page PTY sessions
12
- export const activeTerminalId = signal(null); // which one's open in the right pane
13
- export const recent = signal([]);
14
- export const recentTotal = signal(0);
15
- export const favorites = signal({}); // { sessionId: {sessionId, cwd, title, gitBranch, addedAt} }
16
- export const labels = signal({}); // { sessionId: customLabel }
13
+ export const folders = signal([]); // [{id,name,order,createdAt}]
17
14
  export const workspaces = signal([]);
18
- export const snapshot = signal(null);
19
- export const history = signal([]);
20
15
  export const serverHealth = signal({ state: 'connecting' });
21
16
 
22
17
  // ── ui state (persisted in localStorage where noted) ───────────
23
18
  export const activeTab = signal('sessions');
19
+ export const activeSessionId = signal(null); // the session currently rendered in the right pane
24
20
  export const sidebarCollapsed = signal(false);
25
- // fold state for the three cards on the Sessions tab
26
- export const cardFolded = signal({ favorites: false, sessions: false, recent: 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({});
27
31
  export const configDirty = signal(false);
28
- export const modalOpen = 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({});
29
36
  export const clockTick = signal(Date.now()); // re-ticked each second so fmtAgo refreshes
30
37
  export const lastRefreshAt = signal(0); // ms timestamp of last successful refreshAll()
31
38
  export const installPrompt = signal(null); // captured beforeinstallprompt event (PWA install)
32
- export const isInstalledPwa = signal(false); // running inside an installed PWA window (display-mode: standalone+)
33
-
34
- // ── pagination ──────────────────────────────────────────────────
35
- export const sessionsOffset = signal(0);
36
- export const sessionsLimit = signal(10);
37
- export const favoritesOffset = signal(0);
38
- export const favoritesLimit = signal(10);
39
- export const recentOffset = signal(0);
40
- export const recentLimit = signal(10);
39
+ export const isInstalledPwa = signal(false); // running inside an installed PWA window
41
40
 
42
41
  // ── derived ─────────────────────────────────────────────────────
43
- export const favoritesList = computed(() =>
44
- Object.values(favorites.value).sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0))
45
- );
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
+ });
46
65
 
47
66
  export const TAB_HEADINGS = {
48
- sessions: { title: 'Sessions', subtitle: 'Live and recently-closed Claude Code sessions on this machine.' },
49
- launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace, or restore from snapshot.' },
50
- terminals: { title: 'Terminals', subtitle: 'Claude sessions running in this page.' },
67
+ sessions: { title: 'Sessions', subtitle: 'Sessions you started in ccsm.' },
68
+ launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace.' },
51
69
  configure: { title: 'Configure', subtitle: 'Persisted to ~/.ccsm/config.json.' },
52
- about: { title: 'About', subtitle: 'ccsm — Claude Code Session Manager.' },
70
+ about: { title: 'About', subtitle: 'ccsm — Claude CLI Sessions Manager.' },
53
71
  };
54
72
 
55
73
  // ── persistence helpers (localStorage) ──────────────────────────
56
74
  const LS_SIDEBAR = 'ccsm.sidebar-collapsed';
57
- const LS_FOLD = (k) => `ccsm.fold.${k}`;
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';
58
88
 
59
89
  export function loadPersisted() {
60
90
  sidebarCollapsed.value = localStorage.getItem(LS_SIDEBAR) === 'true';
61
- const folds = { ...cardFolded.value };
62
- for (const k of Object.keys(folds)) {
63
- folds[k] = localStorage.getItem(LS_FOLD(k)) === '1';
91
+ const w = Number(localStorage.getItem(LS_SIDEBAR_W));
92
+ if (Number.isFinite(w) && w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) {
93
+ sidebarWidth.value = w;
64
94
  }
65
- cardFolded.value = folds;
66
-
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 {}
67
123
  const hash = location.hash.slice(1);
68
124
  if (TAB_HEADINGS[hash]) activeTab.value = hash;
69
125
  }
70
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
+
71
201
  // ── actions ─────────────────────────────────────────────────────
72
202
  export function selectTab(name) {
73
203
  if (!TAB_HEADINGS[name]) name = 'sessions';
@@ -75,13 +205,27 @@ export function selectTab(name) {
75
205
  if (location.hash !== `#${name}`) window.history.replaceState(null, '', `#${name}`);
76
206
  }
77
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
+
78
214
  export function toggleSidebar() {
215
+ if (sidebarForcedCollapsed.value) return;
79
216
  sidebarCollapsed.value = !sidebarCollapsed.value;
80
217
  localStorage.setItem(LS_SIDEBAR, String(sidebarCollapsed.value));
81
218
  }
82
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
+
83
227
  export function toggleCardFold(key) {
84
228
  const next = { ...cardFolded.value, [key]: !cardFolded.value[key] };
85
229
  cardFolded.value = next;
86
- localStorage.setItem(LS_FOLD(key), next[key] ? '1' : '0');
230
+ try { localStorage.setItem(`ccsm.fold.${key}`, next[key] ? '1' : '0'); } catch {}
87
231
  }
@@ -1,6 +1,6 @@
1
1
  {
2
- "name": "ccsm — Claude CLI Sessions Manager",
3
- "short_name": "ccsm",
2
+ "name": "CCSM",
3
+ "short_name": "CCSM",
4
4
  "description": "Single pane over every live claude session on this machine.",
5
5
  "start_url": "./",
6
6
  "scope": "./",
@@ -3,7 +3,7 @@
3
3
 
4
4
  // ccsm postinstall · Windows-only · runs after `npm install -g @bakapiano/ccsm`.
5
5
  // Registers the `ccsm://` URL protocol in HKCU so the hosted frontend
6
- // (https://bakapiano.github.io/cssm/v1/) can fire `<a href="ccsm://start">`
6
+ // (https://bakapiano.github.io/ccsm/v1/) can fire `<a href="ccsm://start">`
7
7
  // from its OfflineBanner and have Windows spawn the backend on demand.
8
8
  //
9
9
  // Best-effort: any failure MUST NOT break npm install. Each step is in