@bakapiano/ccsm 0.9.0 → 0.10.1

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 (69) hide show
  1. package/CLAUDE.md +222 -195
  2. package/README.md +77 -79
  3. package/lib/cliSessionWatcher.js +249 -0
  4. package/lib/config.js +101 -24
  5. package/lib/folders.js +96 -0
  6. package/lib/localCliSessions.js +177 -0
  7. package/lib/persistedSessions.js +134 -0
  8. package/lib/webTerminal.js +31 -18
  9. package/lib/workspace.js +26 -4
  10. package/package.json +1 -1
  11. package/public/assets/claude-color.svg +1 -0
  12. package/public/assets/codex-color.svg +1 -0
  13. package/public/assets/copilot-color.svg +1 -0
  14. package/public/css/base.css +22 -5
  15. package/public/css/cards.css +37 -3
  16. package/public/css/feedback.css +127 -43
  17. package/public/css/forms.css +97 -25
  18. package/public/css/layout.css +74 -26
  19. package/public/css/modal.css +40 -26
  20. package/public/css/responsive.css +2 -2
  21. package/public/css/sidebar.css +424 -25
  22. package/public/css/terminals.css +138 -0
  23. package/public/css/tokens.css +28 -12
  24. package/public/css/wco.css +38 -39
  25. package/public/css/widgets.css +1177 -6
  26. package/public/index.html +35 -2
  27. package/public/js/api.js +194 -37
  28. package/public/js/components/AdoptModal.js +171 -0
  29. package/public/js/components/App.js +1 -11
  30. package/public/js/components/DirectoryPicker.js +203 -0
  31. package/public/js/components/EntityFormModal.js +105 -0
  32. package/public/js/components/Modal.js +51 -0
  33. package/public/js/components/OfflineBanner.js +29 -23
  34. package/public/js/components/PageTitleBar.js +13 -0
  35. package/public/js/components/Picker.js +179 -0
  36. package/public/js/components/Popover.js +55 -0
  37. package/public/js/components/Sidebar.js +219 -32
  38. package/public/js/components/TerminalView.js +27 -3
  39. package/public/js/components/useDragSort.js +67 -0
  40. package/public/js/dialog.js +10 -2
  41. package/public/js/icons.js +66 -3
  42. package/public/js/main.js +54 -3
  43. package/public/js/pages/AboutPage.js +80 -0
  44. package/public/js/pages/ConfigurePage.js +429 -207
  45. package/public/js/pages/LaunchPage.js +326 -86
  46. package/public/js/pages/SessionsPage.js +91 -41
  47. package/public/js/state.js +102 -73
  48. package/public/manifest.webmanifest +2 -2
  49. package/scripts/install.js +7 -2
  50. package/server.js +755 -441
  51. package/lib/favorites.js +0 -51
  52. package/lib/focus.js +0 -369
  53. package/lib/labels.js +0 -29
  54. package/lib/launcher.js +0 -219
  55. package/lib/sessions.js +0 -272
  56. package/lib/snapshot.js +0 -141
  57. package/public/js/actions.js +0 -107
  58. package/public/js/components/Fab.js +0 -11
  59. package/public/js/components/FavoritesTable.js +0 -81
  60. package/public/js/components/Footer.js +0 -12
  61. package/public/js/components/NewSessionModal.js +0 -153
  62. package/public/js/components/PageHead.js +0 -33
  63. package/public/js/components/Pagination.js +0 -27
  64. package/public/js/components/RecentTable.js +0 -68
  65. package/public/js/components/SessionsTable.js +0 -71
  66. package/public/js/components/SnapshotPanel.js +0 -77
  67. package/public/js/components/TitleCell.js +0 -40
  68. package/public/js/components/WorkspacesGrid.js +0 -41
  69. package/public/js/pages/TerminalsPage.js +0 -74
@@ -5,67 +5,86 @@ 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);
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
25
  export const sidebarWidth = signal(232); // px when expanded, persisted in localStorage
26
- export const accentColor = signal('#b3614a'); // user-chosen brand accent, persisted
27
- // fold state for the three cards on the Sessions tab
28
- export const cardFolded = signal({ favorites: false, sessions: false, recent: false });
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({});
29
31
  export const configDirty = signal(false);
30
- 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({});
31
36
  export const clockTick = signal(Date.now()); // re-ticked each second so fmtAgo refreshes
32
37
  export const lastRefreshAt = signal(0); // ms timestamp of last successful refreshAll()
33
38
  export const installPrompt = signal(null); // captured beforeinstallprompt event (PWA install)
34
- export const isInstalledPwa = signal(false); // running inside an installed PWA window (display-mode: standalone+)
35
-
36
- // ── pagination ──────────────────────────────────────────────────
37
- export const sessionsOffset = signal(0);
38
- export const sessionsLimit = signal(10);
39
- export const favoritesOffset = signal(0);
40
- export const favoritesLimit = signal(10);
41
- export const recentOffset = signal(0);
42
- export const recentLimit = signal(10);
39
+ export const isInstalledPwa = signal(false); // running inside an installed PWA window
43
40
 
44
41
  // ── derived ─────────────────────────────────────────────────────
45
- export const favoritesList = computed(() =>
46
- Object.values(favorites.value).sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0))
47
- );
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
+ });
48
65
 
49
66
  export const TAB_HEADINGS = {
50
- sessions: { title: 'Sessions', subtitle: 'Live and recently-closed Claude Code sessions on this machine.' },
51
- launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace, or restore from snapshot.' },
52
- 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.' },
53
69
  configure: { title: 'Configure', subtitle: 'Persisted to ~/.ccsm/config.json.' },
54
- about: { title: 'About', subtitle: 'ccsm — Claude Code Session Manager.' },
70
+ about: { title: 'About', subtitle: 'ccsm — Claude CLI Sessions Manager.' },
55
71
  };
56
72
 
57
73
  // ── persistence helpers (localStorage) ──────────────────────────
58
74
  const LS_SIDEBAR = 'ccsm.sidebar-collapsed';
59
75
  const LS_SIDEBAR_W = 'ccsm.sidebar-width';
60
76
  const LS_ACCENT = 'ccsm.accent';
61
- const LS_FOLD = (k) => `ccsm.fold.${k}`;
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';
62
83
 
63
- // Resizable sidebar width (when not collapsed). Clamp range matches the
64
- // CSS min/max — too narrow truncates labels, too wide eats main content.
65
84
  export const SIDEBAR_MIN = 180;
66
85
  export const SIDEBAR_MAX = 400;
67
86
  export const SIDEBAR_DEFAULT = 232;
68
- export const ACCENT_DEFAULT = '#b3614a';
87
+ export const ACCENT_DEFAULT = '#2f6fa3';
69
88
 
70
89
  export function loadPersisted() {
71
90
  sidebarCollapsed.value = localStorage.getItem(LS_SIDEBAR) === 'true';
@@ -75,23 +94,36 @@ export function loadPersisted() {
75
94
  }
76
95
  applySidebarWidthCssVar();
77
96
  const a = localStorage.getItem(LS_ACCENT);
78
- if (isHexColor(a)) {
79
- accentColor.value = a;
80
- }
97
+ if (isHexColor(a)) accentColor.value = a;
81
98
  applyAccentCssVars();
82
- const folds = { ...cardFolded.value };
83
- for (const k of Object.keys(folds)) {
84
- folds[k] = localStorage.getItem(LS_FOLD(k)) === '1';
85
- }
86
- cardFolded.value = folds;
87
-
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 {}
88
123
  const hash = location.hash.slice(1);
89
124
  if (TAB_HEADINGS[hash]) activeTab.value = hash;
90
125
  }
91
126
 
92
- // Push the current sidebar width into the CSS custom property so the grid
93
- // in layout.css picks it up. Called on load and whenever the user drags
94
- // the handle.
95
127
  function applySidebarWidthCssVar() {
96
128
  document.documentElement.style.setProperty('--sidebar-w', `${sidebarWidth.value}px`);
97
129
  }
@@ -104,9 +136,6 @@ export function setSidebarWidth(px) {
104
136
  }
105
137
 
106
138
  // ── theme accent ────────────────────────────────────────────────
107
- // We expose 4 derived CSS vars: --accent, --accent-deep, --accent-soft,
108
- // --accent-softer. The user only picks the base; deep/soft are computed
109
- // (darken / rgba alpha) so things stay self-consistent.
110
139
  function isHexColor(s) {
111
140
  return typeof s === 'string' && /^#[0-9a-fA-F]{6}$/.test(s);
112
141
  }
@@ -119,17 +148,8 @@ function rgbToHex({ r, g, b }) {
119
148
  return `#${h(r)}${h(g)}${h(b)}`;
120
149
  }
121
150
  function darken({ r, g, b }, amount) {
122
- // amount 0..1; pull each channel toward 0
123
151
  return { r: r * (1 - amount), g: g * (1 - amount), b: b * (1 - amount) };
124
152
  }
125
- function lighten({ r, g, b }, amount) {
126
- // amount 0..1; pull each channel toward 255
127
- return { r: r + (255 - r) * amount, g: g + (255 - g) * amount, b: b + (255 - b) * amount };
128
- }
129
- // Mix the accent into white at a tiny ratio to get a faint warm/cool tint
130
- // for surfaces. `t` controls strength (0 = pure white, 1 = pure accent).
131
- // Surfaces use very low t (0.02–0.08) so the page reads as "white with
132
- // a hint of the brand color" rather than colored.
133
153
  function mixWithWhite({ r, g, b }, t) {
134
154
  return { r: r * t + 255 * (1 - t), g: g * t + 255 * (1 - t), b: b * t + 255 * (1 - t) };
135
155
  }
@@ -139,23 +159,19 @@ function applyAccentCssVars() {
139
159
  const deep = rgbToHex(darken(rgb, 0.2));
140
160
  const soft = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.10)`;
141
161
  const softer = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.04)`;
142
- // Surface tints derived from the accent. Each surface keeps its
143
- // relative brightness from the original palette (cream → white → cream
144
- // hover → cream active) but its hue follows the chosen accent. Mixed
145
- // weights chosen to roughly match the warm copper defaults:
146
- // --bg was #faf9f5 → t≈0.04
147
- // --bg-elev was #ffffff → t=0 (kept pure white)
148
- // --sidebar-hover was #f0ece0 → t≈0.10
149
- // --sidebar-active was #e8e3d5 → t≈0.15
150
- // --border was #e8e3d5 → t≈0.15
151
- // --border-soft was #ece8da → t≈0.12
152
- // --border-strong was #d4cdb8 → t≈0.25
153
162
  const bg = rgbToHex(mixWithWhite(rgb, 0.04));
154
163
  const sidebarHover = rgbToHex(mixWithWhite(rgb, 0.10));
155
164
  const sidebarActive= rgbToHex(mixWithWhite(rgb, 0.15));
156
165
  const border = rgbToHex(mixWithWhite(rgb, 0.15));
157
166
  const borderSoft = rgbToHex(mixWithWhite(rgb, 0.12));
158
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
159
175
  const root = document.documentElement.style;
160
176
  root.setProperty('--accent', base);
161
177
  root.setProperty('--accent-deep', deep);
@@ -168,10 +184,9 @@ function applyAccentCssVars() {
168
184
  root.setProperty('--border', border);
169
185
  root.setProperty('--border-soft', borderSoft);
170
186
  root.setProperty('--border-strong', borderStrong);
171
- // --bg-elev stays pure white so cards "lift" off the tinted surface.
172
- // Sync the meta theme-color to the tinted surface so the OS title bar
173
- // matches what the user sees (was previously the raw accent — too
174
- // saturated).
187
+ root.setProperty('--ui-bg', uiBg);
188
+ root.setProperty('--ui-border', uiBorder);
189
+ root.setProperty('--ui-border-soft', uiBorderSoft);
175
190
  const meta = document.querySelector('meta[name="theme-color"]');
176
191
  if (meta) meta.setAttribute('content', bg);
177
192
  }
@@ -190,13 +205,27 @@ export function selectTab(name) {
190
205
  if (location.hash !== `#${name}`) window.history.replaceState(null, '', `#${name}`);
191
206
  }
192
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
+
193
214
  export function toggleSidebar() {
215
+ if (sidebarForcedCollapsed.value) return;
194
216
  sidebarCollapsed.value = !sidebarCollapsed.value;
195
217
  localStorage.setItem(LS_SIDEBAR, String(sidebarCollapsed.value));
196
218
  }
197
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
+
198
227
  export function toggleCardFold(key) {
199
228
  const next = { ...cardFolded.value, [key]: !cardFolded.value[key] };
200
229
  cardFolded.value = next;
201
- localStorage.setItem(LS_FOLD(key), next[key] ? '1' : '0');
230
+ try { localStorage.setItem(`ccsm.fold.${key}`, next[key] ? '1' : '0'); } catch {}
202
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": "./",
@@ -116,11 +116,16 @@ try {
116
116
  if (process.env.CCSM_NO_AUTOLAUNCH !== '1') {
117
117
  try {
118
118
  const { spawn } = require('node:child_process');
119
- const child = spawn(ccsmCmd, [], {
119
+ // Spawn `node bin/ccsm.js` directly — NOT ccsm.cmd. On Windows,
120
+ // child_process.spawn() with shell:false refuses .cmd files (throws
121
+ // EINVAL); using shell:true would flash a console window. Going
122
+ // through node + the JS entrypoint sidesteps both problems and
123
+ // matches exactly what the .cmd shim would have invoked.
124
+ const launcherJs = path.join(__dirname, '..', 'bin', 'ccsm.js');
125
+ const child = spawn(process.execPath, [launcherJs], {
120
126
  detached: true,
121
127
  stdio: 'ignore',
122
128
  windowsHide: true,
123
- shell: false,
124
129
  });
125
130
  child.unref();
126
131
  log('launching ccsm now · check for the chromeless window');