@bakapiano/ccsm 0.5.0 → 0.8.3

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/README.md +172 -38
  2. package/bin/ccsm.js +194 -0
  3. package/lib/config.js +1 -0
  4. package/lib/favorites.js +23 -45
  5. package/lib/focus.js +90 -14
  6. package/lib/jsonStore.js +60 -0
  7. package/lib/labels.js +29 -0
  8. package/lib/webTerminal.js +173 -0
  9. package/lib/workspace.js +8 -4
  10. package/package.json +11 -3
  11. package/public/css/base.css +82 -0
  12. package/public/css/cards.css +149 -0
  13. package/public/css/feedback.css +219 -0
  14. package/public/css/forms.css +282 -0
  15. package/public/css/layout.css +107 -0
  16. package/public/css/modal.css +169 -0
  17. package/public/css/responsive.css +10 -0
  18. package/public/css/sidebar.css +165 -0
  19. package/public/css/tables.css +266 -0
  20. package/public/css/terminals.css +112 -0
  21. package/public/css/tokens.css +63 -0
  22. package/public/css/wco.css +70 -0
  23. package/public/css/widgets.css +204 -0
  24. package/public/favicon.svg +18 -0
  25. package/public/index.html +53 -379
  26. package/public/js/actions.js +87 -0
  27. package/public/js/api.js +103 -0
  28. package/public/js/backend.js +28 -0
  29. package/public/js/components/App.js +45 -0
  30. package/public/js/components/Card.js +24 -0
  31. package/public/js/components/DialogHost.js +45 -0
  32. package/public/js/components/Fab.js +11 -0
  33. package/public/js/components/FavoritesTable.js +81 -0
  34. package/public/js/components/Footer.js +12 -0
  35. package/public/js/components/NewSessionModal.js +142 -0
  36. package/public/js/components/OfflineBanner.js +52 -0
  37. package/public/js/components/PageHead.js +33 -0
  38. package/public/js/components/Pagination.js +27 -0
  39. package/public/js/components/ProgressList.js +32 -0
  40. package/public/js/components/RecentTable.js +68 -0
  41. package/public/js/components/RepoPicker.js +40 -0
  42. package/public/js/components/ReposEditor.js +74 -0
  43. package/public/js/components/ServerStatus.js +18 -0
  44. package/public/js/components/SessionsTable.js +71 -0
  45. package/public/js/components/Sidebar.js +52 -0
  46. package/public/js/components/SnapshotPanel.js +77 -0
  47. package/public/js/components/TerminalView.js +108 -0
  48. package/public/js/components/TitleCell.js +40 -0
  49. package/public/js/components/Toast.js +8 -0
  50. package/public/js/components/WorkspacePicker.js +19 -0
  51. package/public/js/components/WorkspacesGrid.js +41 -0
  52. package/public/js/dialog.js +59 -0
  53. package/public/js/html.js +6 -0
  54. package/public/js/icons.js +114 -0
  55. package/public/js/main.js +81 -0
  56. package/public/js/pages/AboutPage.js +85 -0
  57. package/public/js/pages/ConfigurePage.js +194 -0
  58. package/public/js/pages/LaunchPage.js +117 -0
  59. package/public/js/pages/SessionsPage.js +47 -0
  60. package/public/js/pages/TerminalsPage.js +74 -0
  61. package/public/js/state.js +87 -0
  62. package/public/js/streaming.js +96 -0
  63. package/public/js/toast.js +14 -0
  64. package/public/js/util.js +24 -0
  65. package/public/manifest.webmanifest +14 -0
  66. package/scripts/install.js +111 -0
  67. package/scripts/uninstall.js +56 -0
  68. package/server.js +314 -31
  69. package/public/app.js +0 -894
  70. package/public/styles.css +0 -1204
package/public/app.js DELETED
@@ -1,894 +0,0 @@
1
- 'use strict';
2
-
3
- /* ─────────────────────────────────────────────────────────────
4
- ccsm · frontend · v0.6 (light sidebar)
5
- ───────────────────────────────────────────────────────────── */
6
-
7
- const $ = (sel) => document.querySelector(sel);
8
- const $$ = (sel) => Array.from(document.querySelectorAll(sel));
9
-
10
- const state = {
11
- config: null,
12
- terminals: [],
13
- sessions: [],
14
- recent: [],
15
- recentTotal: 0,
16
- recentOffset: 0,
17
- recentLimit: 15,
18
- favorites: {}, // { sessionId: { sessionId, cwd, title, gitBranch, addedAt, label } }
19
- workspaces: [],
20
- snapshot: null,
21
- history: [],
22
- autoTimer: null,
23
- clockTimer: null,
24
- activeTab: 'sessions',
25
- // Tables that have already had their first render — used to suppress the
26
- // row stagger animation on subsequent re-renders so 5s auto-refresh
27
- // doesn't strobe.
28
- renderedTables: new Set(),
29
- };
30
-
31
- const TAB_HEADINGS = {
32
- sessions: { title: 'Sessions', subtitle: 'Live and recently-closed Claude Code sessions on this machine.' },
33
- launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace, or restore from snapshot.' },
34
- configure: { title: 'Configure', subtitle: 'Persisted to ~/.ccsm/config.json.' },
35
- };
36
-
37
- /* ── API ── */
38
- async function api(method, url, body) {
39
- const opts = { method, headers: { 'Content-Type': 'application/json' } };
40
- if (body !== undefined) opts.body = JSON.stringify(body);
41
- const r = await fetch(url, opts);
42
- const text = await r.text();
43
- let json;
44
- try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
45
- if (!r.ok) throw new Error(json.error || `HTTP ${r.status}`);
46
- return json;
47
- }
48
-
49
- /* ── toast ── */
50
- const toastEl = $('#toast');
51
- let toastT;
52
- function toast(msg, kind = 'ok') {
53
- toastEl.textContent = msg;
54
- toastEl.className = `toast show ${kind}`;
55
- clearTimeout(toastT);
56
- toastT = setTimeout(() => toastEl.classList.remove('show'), 3200);
57
- }
58
-
59
- /* ── fmt ── */
60
- function fmtTime(ms) {
61
- if (!ms) return '—';
62
- return new Date(ms).toLocaleString(undefined, { hour12: false });
63
- }
64
- function fmtAgo(ms) {
65
- if (!ms) return '—';
66
- const sec = Math.floor((Date.now() - ms) / 1000);
67
- if (sec < 60) return `${sec}s`;
68
- if (sec < 3600) return `${Math.floor(sec / 60)}m`;
69
- if (sec < 86400) return `${Math.floor(sec / 3600)}h`;
70
- return `${Math.floor(sec / 86400)}d`;
71
- }
72
- function escapeHtml(s) {
73
- return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
74
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
75
- }[c]));
76
- }
77
-
78
- /* Mark a table as already-rendered so animations don't replay on
79
- subsequent updates. Call after the first innerHTML population. */
80
- function markRendered(tableId) {
81
- const tb = document.querySelector(`#${tableId} tbody`);
82
- if (!tb) return;
83
- if (state.renderedTables.has(tableId)) {
84
- tb.classList.add('no-anim');
85
- } else {
86
- state.renderedTables.add(tableId);
87
- // first render: animation runs. We schedule no-anim for next paint
88
- // so the very next re-render doesn't restage.
89
- requestAnimationFrame(() => {
90
- requestAnimationFrame(() => tb.classList.add('no-anim'));
91
- });
92
- }
93
- }
94
-
95
- const STAR_SVG_OUTLINE =
96
- `<svg class="star-icon" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
97
- <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
98
- </svg>`;
99
- const STAR_SVG_FILLED =
100
- `<svg class="star-icon" viewBox="0 0 24 24" width="15" height="15" fill="currentColor" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
101
- <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
102
- </svg>`;
103
-
104
- function starButtonHtml(sessionId, isFav) {
105
- return `<button class="star-btn ${isFav ? 'is-fav' : ''}" data-star="${escapeHtml(sessionId)}" title="${isFav ? 'remove favorite' : 'add favorite'}" aria-label="${isFav ? 'remove favorite' : 'add favorite'}">${isFav ? STAR_SVG_FILLED : STAR_SVG_OUTLINE}</button>`;
106
- }
107
-
108
- /* ─────────────────────────────────────────────────────────────
109
- Sidebar — tabs + collapse
110
- ───────────────────────────────────────────────────────────── */
111
-
112
- function selectTab(name) {
113
- if (!TAB_HEADINGS[name]) name = 'sessions';
114
- state.activeTab = name;
115
- $$('.nav-item').forEach((b) => {
116
- b.setAttribute('aria-selected', b.dataset.tab === name ? 'true' : 'false');
117
- });
118
- $$('.tab-panel').forEach((p) => {
119
- if (p.dataset.panel === name) p.setAttribute('data-active', '');
120
- else p.removeAttribute('data-active');
121
- });
122
- const h = TAB_HEADINGS[name];
123
- $('#pageTitle').textContent = h.title;
124
- $('#pageSubtitle').textContent = h.subtitle;
125
- if (location.hash !== `#${name}`) history.replaceState(null, '', `#${name}`);
126
- }
127
-
128
- function toggleSidebar() {
129
- const sb = $('#sidebar');
130
- const collapsed = sb.getAttribute('data-collapsed') === 'true';
131
- sb.setAttribute('data-collapsed', collapsed ? 'false' : 'true');
132
- localStorage.setItem('ccsm.sidebar-collapsed', collapsed ? 'false' : 'true');
133
- }
134
- function restoreSidebar() {
135
- const v = localStorage.getItem('ccsm.sidebar-collapsed');
136
- if (v === 'true') $('#sidebar').setAttribute('data-collapsed', 'true');
137
- }
138
-
139
- /* ─────────────────────────────────────────────────────────────
140
- Render: sessions (live)
141
- ───────────────────────────────────────────────────────────── */
142
-
143
- function renderSessions() {
144
- const tb = $('#sessionsTable tbody');
145
- tb.innerHTML = '';
146
- for (const s of state.sessions) {
147
- const isFav = !!state.favorites[s.sessionId];
148
- const tr = document.createElement('tr');
149
- tr.innerHTML = `
150
- <td><span class="status-mark ${escapeHtml(s.status)}" title="${escapeHtml(s.status)}"></span></td>
151
- <td>
152
- <div class="title-cell">
153
- <div class="title-row">
154
- <span class="primary" title="${escapeHtml(s.title || '')}">${escapeHtml(s.title || '(no title)')}</span>
155
- ${starButtonHtml(s.sessionId, isFav)}
156
- </div>
157
- <div class="secondary" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0, 8))}${s.version ? ' · ' + escapeHtml(s.version) : ''}</div>
158
- </div>
159
- </td>
160
- <td><div class="path-cell" title="${escapeHtml(s.cwd)}">${escapeHtml(s.cwd)}</div></td>
161
- <td class="num" title="${escapeHtml(fmtTime(s.updatedAt))}">${escapeHtml(fmtAgo(s.updatedAt))}</td>
162
- <td class="num" title="${escapeHtml(fmtTime(s.startedAt))}">${escapeHtml(fmtAgo(s.startedAt))}</td>
163
- <td class="num">${escapeHtml(String(s.pid))}</td>
164
- <td>
165
- <div class="row-actions">
166
- <button class="action small primary" data-focus="${escapeHtml(s.sessionId)}" title="raise the wt window already running this session">Focus</button>
167
- <button class="action small" data-resume="${escapeHtml(s.sessionId)}" data-cwd="${escapeHtml(s.cwd)}" title="open a new wt window with claude --resume">Resume new ↗</button>
168
- </div>
169
- </td>
170
- `;
171
- tr.dataset.cwd = s.cwd;
172
- tr.dataset.title = s.title || '';
173
- tb.appendChild(tr);
174
- }
175
- $('#sessionsEmpty').hidden = state.sessions.length > 0;
176
- const ts = new Date().toLocaleTimeString(undefined, { hour12: false });
177
- $('#sessionsMeta').textContent = state.sessions.length
178
- ? `${state.sessions.length} live · refreshed ${ts}`
179
- : 'no live sessions';
180
- $('#navCount-sessions').textContent = state.sessions.length;
181
- markRendered('sessionsTable');
182
- }
183
-
184
- /* ─────────────────────────────────────────────────────────────
185
- Render: recently closed
186
- ───────────────────────────────────────────────────────────── */
187
-
188
- function renderRecent() {
189
- const tb = $('#recentTable tbody');
190
- tb.innerHTML = '';
191
- const recent = state.recent || [];
192
- for (const s of recent) {
193
- const isFav = !!state.favorites[s.sessionId];
194
- const tr = document.createElement('tr');
195
- tr.innerHTML = `
196
- <td>
197
- <div class="title-cell">
198
- <div class="title-row">
199
- <span class="primary" title="${escapeHtml(s.title || '')}">${escapeHtml(s.title || '(no title)')}</span>
200
- ${starButtonHtml(s.sessionId, isFav)}
201
- </div>
202
- <div class="secondary" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0, 8))}</div>
203
- </div>
204
- </td>
205
- <td><div class="path-cell" title="${escapeHtml(s.cwd || '')}">${escapeHtml(s.cwd || '')}</div></td>
206
- <td>${s.gitBranch ? `<span class="branch-tag">${escapeHtml(s.gitBranch)}</span>` : '<span class="muted-text">—</span>'}</td>
207
- <td class="num" title="${escapeHtml(fmtTime(s.updatedAt))}">${escapeHtml(fmtAgo(s.updatedAt))}</td>
208
- <td class="num" title="${escapeHtml(fmtTime(s.startedAt))}">${escapeHtml(fmtAgo(s.startedAt))}</td>
209
- <td>
210
- <div class="row-actions">
211
- <button class="action small primary" data-continue="${escapeHtml(s.sessionId)}" data-cwd="${escapeHtml(s.cwd)}" title="claude --resume in a fresh wt window">Continue ↗</button>
212
- </div>
213
- </td>
214
- `;
215
- tr.dataset.cwd = s.cwd || '';
216
- tr.dataset.title = s.title || '';
217
- tr.dataset.gitBranch = s.gitBranch || '';
218
- tb.appendChild(tr);
219
- }
220
- $('#recentEmpty').hidden = recent.length > 0;
221
- // Pagination footer
222
- const total = state.recentTotal;
223
- const limit = state.recentLimit;
224
- const offset = state.recentOffset;
225
- const pageNum = Math.floor(offset / limit) + 1;
226
- const pageTotal = Math.max(1, Math.ceil(total / limit));
227
- $('#recentMeta').textContent = total
228
- ? `${total} total · sorted by jsonl mtime, excluding live`
229
- : 'no recent sessions';
230
- if (total > limit) {
231
- $('#recentPagination').hidden = false;
232
- $('#recentPageNum').textContent = pageNum;
233
- $('#recentPageTotal').textContent = pageTotal;
234
- $('#recentTotal').textContent = total;
235
- $('#recentPrevBtn').disabled = offset === 0;
236
- $('#recentNextBtn').disabled = offset + limit >= total;
237
- } else {
238
- $('#recentPagination').hidden = true;
239
- }
240
- markRendered('recentTable');
241
- }
242
-
243
- /* ─────────────────────────────────────────────────────────────
244
- Render: favorites
245
- ───────────────────────────────────────────────────────────── */
246
- function renderFavorites() {
247
- const tb = $('#favoritesTable tbody');
248
- tb.innerHTML = '';
249
- const list = Object.values(state.favorites).sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
250
- for (const f of list) {
251
- const liveMatch = state.sessions.find((s) => s.sessionId === f.sessionId);
252
- const title = liveMatch?.title || f.title;
253
- const cwd = liveMatch?.cwd || f.cwd;
254
- const branch = f.gitBranch;
255
- const actions = liveMatch
256
- ? `<button class="action small primary" data-focus="${escapeHtml(f.sessionId)}" title="raise the wt window">Focus</button>
257
- <button class="action small" data-resume="${escapeHtml(f.sessionId)}" data-cwd="${escapeHtml(cwd)}" title="claude --resume in a fresh wt window">Resume new ↗</button>`
258
- : `<button class="action small primary" data-continue="${escapeHtml(f.sessionId)}" data-cwd="${escapeHtml(cwd || '')}" ${cwd ? '' : 'disabled'} title="claude --resume in a fresh wt window">Continue ↗</button>`;
259
- const tr = document.createElement('tr');
260
- tr.innerHTML = `
261
- <td>
262
- <div class="title-cell">
263
- <div class="title-row">
264
- <span class="primary" title="${escapeHtml(title || '')}">${escapeHtml(title || '(no title)')}</span>
265
- ${starButtonHtml(f.sessionId, true)}
266
- </div>
267
- <div class="secondary" title="${escapeHtml(f.sessionId)}">
268
- ${escapeHtml(f.sessionId.slice(0, 8))}${liveMatch ? ` · <span style="color:var(--green);">live</span>` : ''}
269
- </div>
270
- </div>
271
- </td>
272
- <td><div class="path-cell" title="${escapeHtml(cwd || '')}">${escapeHtml(cwd || '')}</div></td>
273
- <td>${branch ? `<span class="branch-tag">${escapeHtml(branch)}</span>` : '<span class="muted-text">—</span>'}</td>
274
- <td class="num" title="${escapeHtml(fmtTime(f.addedAt))}">${escapeHtml(fmtAgo(f.addedAt))}</td>
275
- <td><div class="row-actions">${actions}</div></td>
276
- `;
277
- tr.dataset.cwd = cwd || '';
278
- tr.dataset.title = title || '';
279
- tb.appendChild(tr);
280
- }
281
- const count = list.length;
282
- $('#favoritesEmpty').style.display = count === 0 ? 'block' : 'none';
283
- $('#favoritesTable').style.display = count === 0 ? 'none' : 'table';
284
- $('#favoritesMeta').textContent = count
285
- ? `${count} pinned`
286
- : 'click ☆ on any row to pin sessions here';
287
- markRendered('favoritesTable');
288
- }
289
-
290
- /* ─────────────────────────────────────────────────────────────
291
- Render: snapshot
292
- ───────────────────────────────────────────────────────────── */
293
-
294
- function renderSnapshot() {
295
- const snap = state.snapshot;
296
- if (!snap) {
297
- $('#snapshotMeta').textContent = 'no snapshot saved yet';
298
- $('#snapshotPreview').textContent = '';
299
- return;
300
- }
301
- $('#snapshotMeta').textContent =
302
- `${snap.sessions.length} session(s) · taken ${fmtAgo(snap.takenAt)} ago (${fmtTime(snap.takenAt)})`;
303
- $('#snapshotPreview').textContent =
304
- snap.sessions.map((s) =>
305
- `${(s.title || s.sessionId.slice(0, 8)).padEnd(44).slice(0, 44)} ${s.cwd}`
306
- ).join('\n');
307
-
308
- const sel = $('#historySelect');
309
- sel.innerHTML = '<option value="">history…</option>' +
310
- state.history.map((h) =>
311
- `<option value="${escapeHtml(h.file)}">${escapeHtml(h.file.replace('.json', ''))}</option>`
312
- ).join('');
313
- }
314
-
315
- /* ─────────────────────────────────────────────────────────────
316
- Render: workspaces
317
- ───────────────────────────────────────────────────────────── */
318
-
319
- function renderWorkspaces() {
320
- const grid = $('#workspaceList');
321
- grid.innerHTML = '';
322
- if (state.workspaces.length === 0) {
323
- grid.innerHTML = '<div class="empty">No workspaces yet — the first launch will create one.</div>';
324
- }
325
- for (const w of state.workspaces) {
326
- const repos = w.repos.map((r) =>
327
- `<span class="ws-repo ${r.cloned ? 'cloned' : ''}" title="${escapeHtml(r.url)}">${escapeHtml(r.name)}${r.cloned ? ' ✓' : ''}</span>`
328
- ).join('');
329
- const card = document.createElement('div');
330
- card.className = 'workspace-card' + (w.inUse ? ' in-use' : '');
331
- card.innerHTML = `
332
- <div class="ws-head">
333
- <div class="ws-name">${escapeHtml(w.name)}</div>
334
- <span class="ws-tag">${w.inUse ? `in use × ${w.sessionsHere.length}` : 'free'}</span>
335
- </div>
336
- <div class="ws-path">${escapeHtml(w.path)}</div>
337
- <div class="ws-repos">${repos}</div>
338
- `;
339
- grid.appendChild(card);
340
- }
341
-
342
- const sel = $('#workspaceSelect');
343
- sel.innerHTML = '<option value="">auto — find or create unused</option>' +
344
- state.workspaces.filter((w) => !w.inUse).map((w) =>
345
- `<option value="${escapeHtml(w.name)}">${escapeHtml(w.name)}</option>`
346
- ).join('');
347
-
348
- if (state.config) $('#workDirDisplay').textContent = state.config.workDir;
349
- }
350
-
351
- /* ─────────────────────────────────────────────────────────────
352
- Render: repo picker
353
- ───────────────────────────────────────────────────────────── */
354
-
355
- function renderRepoPicker() {
356
- const root = $('#repoPicker');
357
- const repos = state.config?.repos || [];
358
- if (repos.length === 0) {
359
- root.innerHTML = '<span class="muted-text">no repos configured · add some in <strong>Configure</strong></span>';
360
- return;
361
- }
362
- root.innerHTML = '';
363
- for (const r of repos) {
364
- const chip = document.createElement('label');
365
- chip.className = 'chip' + (r.defaultSelected ? ' checked' : '');
366
- chip.innerHTML = `<input type="checkbox" data-repo="${escapeHtml(r.name)}" ${r.defaultSelected ? 'checked' : ''}/>${escapeHtml(r.name)}`;
367
- chip.querySelector('input').addEventListener('change', (e) => {
368
- chip.classList.toggle('checked', e.target.checked);
369
- });
370
- root.appendChild(chip);
371
- }
372
- }
373
-
374
- /* ─────────────────────────────────────────────────────────────
375
- Render: config form
376
- ───────────────────────────────────────────────────────────── */
377
-
378
- function renderConfig() {
379
- if (!state.config) return;
380
- $('#cfgPort').value = state.config.port;
381
- $('#cfgWorkDir').value = state.config.workDir;
382
- $('#cfgInterval').value = state.config.snapshotIntervalMs;
383
- $('#cfgKeep').value = state.config.snapshotHistoryKeep;
384
- $('#cfgClaudeCommand').value = state.config.claudeCommand || 'claude';
385
- $('#cfgCommandShell').value = state.config.commandShell || 'pwsh';
386
- $('#cfgAutoFocus').checked = state.config.autoFocusOnLaunch !== false;
387
- $('#cfgBrowserMode').value =
388
- state.config.browserMode ||
389
- (state.config.autoOpenBrowser === false ? 'none' : 'app');
390
-
391
- const termSel = $('#cfgTerminal');
392
- termSel.innerHTML = (state.terminals || []).map((t) =>
393
- `<option value="${escapeHtml(t.name)}" ${t.name === state.config.terminal ? 'selected' : ''}>${escapeHtml(t.name)} · ${escapeHtml(t.processName)}</option>`
394
- ).join('');
395
-
396
- $('#cfgFinderPrompt').value = state.config.finderPrompt || '';
397
-
398
- const tb = $('#reposTable tbody');
399
- tb.innerHTML = '';
400
- (state.config.repos || []).forEach((r, idx) => {
401
- const tr = document.createElement('tr');
402
- tr.innerHTML = `
403
- <td><input type="text" value="${escapeHtml(r.name)}" data-field="name" data-idx="${idx}" /></td>
404
- <td><input type="text" value="${escapeHtml(r.url)}" data-field="url" data-idx="${idx}" /></td>
405
- <td class="num"><input type="checkbox" data-field="defaultSelected" data-idx="${idx}" ${r.defaultSelected ? 'checked' : ''} /></td>
406
- <td><div class="row-actions"><button class="action tiny danger" data-remove-repo="${idx}">Remove</button></div></td>
407
- `;
408
- tb.appendChild(tr);
409
- });
410
- }
411
-
412
- function readConfigFromForm() {
413
- const repos = $$('#reposTable tbody tr').map((tr) => {
414
- const inputs = tr.querySelectorAll('input');
415
- return {
416
- name: inputs[0].value.trim(),
417
- url: inputs[1].value.trim(),
418
- defaultSelected: inputs[2].checked,
419
- };
420
- }).filter((r) => r.name && r.url);
421
-
422
- return {
423
- port: Number($('#cfgPort').value) || 7777,
424
- workDir: $('#cfgWorkDir').value.trim(),
425
- snapshotIntervalMs: Math.max(5000, Number($('#cfgInterval').value) || 60000),
426
- snapshotHistoryKeep: Math.max(1, Number($('#cfgKeep').value) || 30),
427
- claudeCommand: ($('#cfgClaudeCommand').value || 'claude').trim(),
428
- terminal: $('#cfgTerminal').value || 'wt',
429
- commandShell: $('#cfgCommandShell').value || 'pwsh',
430
- autoFocusOnLaunch: $('#cfgAutoFocus').checked,
431
- browserMode: $('#cfgBrowserMode').value || 'app',
432
- finderPrompt: $('#cfgFinderPrompt').value,
433
- repos,
434
- };
435
- }
436
-
437
- /* ─────────────────────────────────────────────────────────────
438
- Header + footer status
439
- ───────────────────────────────────────────────────────────── */
440
-
441
- function renderHeaderStatus() {
442
- if (!state.config) return;
443
- $('#hdPort').textContent = String(state.config.port);
444
- $('#hdTerminal').textContent =
445
- `${state.config.terminal} · ${state.config.claudeCommand}` +
446
- (state.config.terminal === 'wt' ? ` (${state.config.commandShell})` : '');
447
- $('#footWorkDir').textContent = state.config.workDir;
448
- $('#footData').textContent = '~/.ccsm';
449
- }
450
- function tickClock() {
451
- const t = new Date().toLocaleTimeString(undefined, { hour12: false });
452
- const el = $('#hdTime');
453
- if (el) el.textContent = t;
454
- }
455
-
456
- /* ─────────────────────────────────────────────────────────────
457
- Loaders
458
- ───────────────────────────────────────────────────────────── */
459
-
460
- async function loadConfig() {
461
- const [cfg, terminals] = await Promise.all([
462
- api('GET', '/api/config'),
463
- api('GET', '/api/terminals'),
464
- ]);
465
- state.config = cfg;
466
- state.terminals = terminals.terminals;
467
- renderConfig();
468
- renderRepoPicker();
469
- renderHeaderStatus();
470
- }
471
- async function loadSessions() {
472
- const r = await api('GET', '/api/sessions');
473
- state.sessions = r.sessions;
474
- renderSessions();
475
- }
476
- async function loadRecent() {
477
- const r = await api('GET', `/api/sessions/recent?limit=${state.recentLimit}&offset=${state.recentOffset}`);
478
- state.recent = r.recent;
479
- state.recentTotal = r.total || 0;
480
- state.recentLimit = r.limit || state.recentLimit;
481
- state.recentOffset = r.offset || 0;
482
- renderRecent();
483
- }
484
-
485
- async function loadFavorites() {
486
- try {
487
- const r = await api('GET', '/api/favorites');
488
- const map = {};
489
- for (const f of r.favorites || []) map[f.sessionId] = f;
490
- state.favorites = map;
491
- renderFavorites();
492
- } catch (e) { /* ignore */ }
493
- }
494
-
495
- async function toggleFavorite(sessionId, sourceRow) {
496
- const wasFav = !!state.favorites[sessionId];
497
- if (wasFav) {
498
- // optimistic remove
499
- delete state.favorites[sessionId];
500
- renderFavorites();
501
- renderSessions();
502
- renderRecent();
503
- try { await api('DELETE', `/api/favorites/${sessionId}`); }
504
- catch (e) { toast('unfavorite failed: ' + e.message, 'error'); }
505
- } else {
506
- // optimistic add — snapshot row's data so the favorite is meaningful
507
- // even when the session later moves out of live/recent
508
- const cwd = sourceRow?.dataset?.cwd || '';
509
- const title = sourceRow?.dataset?.title || '';
510
- const gitBranch = sourceRow?.dataset?.gitBranch || '';
511
- state.favorites[sessionId] = { sessionId, cwd, title, gitBranch, addedAt: Date.now() };
512
- renderFavorites();
513
- renderSessions();
514
- renderRecent();
515
- try { await api('POST', `/api/favorites/${sessionId}`, { cwd, title, gitBranch }); }
516
- catch (e) { toast('favorite failed: ' + e.message, 'error'); }
517
- }
518
- }
519
- async function loadSnapshot() {
520
- const r = await api('GET', '/api/snapshot');
521
- state.snapshot = r.snapshot;
522
- const h = await api('GET', '/api/snapshot/history');
523
- state.history = h.history;
524
- renderSnapshot();
525
- }
526
- async function loadWorkspaces() {
527
- const r = await api('GET', '/api/workspaces');
528
- state.workspaces = r.workspaces;
529
- renderWorkspaces();
530
- }
531
- async function refreshAll() {
532
- await Promise.all([loadSessions(), loadRecent(), loadSnapshot(), loadWorkspaces(), loadFavorites()]);
533
- }
534
-
535
- /* ─────────────────────────────────────────────────────────────
536
- Clone progress stream (NDJSON)
537
- ───────────────────────────────────────────────────────────── */
538
-
539
- function resetProgress(repoNames) {
540
- const root = $('#newSessionProgress');
541
- root.innerHTML = '';
542
- for (const r of repoNames) {
543
- const el = document.createElement('div');
544
- el.className = 'progress-item';
545
- el.dataset.repo = r;
546
- el.innerHTML = `
547
- <div class="head">
548
- <span class="name">${escapeHtml(r)}</span>
549
- <span class="phase">queued</span>
550
- <span class="pct"></span>
551
- </div>
552
- <div class="progress-bar"><div class="fill"></div></div>
553
- <div class="detail"></div>
554
- `;
555
- root.appendChild(el);
556
- }
557
- }
558
- function progressItem(repo) {
559
- return document.querySelector(`#newSessionProgress .progress-item[data-repo="${CSS.escape(repo)}"]`);
560
- }
561
- function setProgress(repo, { phase, percent, detail, state, indeterminate } = {}) {
562
- const el = progressItem(repo);
563
- if (!el) return;
564
- if (state) {
565
- el.classList.remove('ok', 'error');
566
- if (state === 'ok' || state === 'error') el.classList.add(state);
567
- }
568
- if (phase != null) el.querySelector('.phase').textContent = phase;
569
- if (percent != null) {
570
- el.querySelector('.pct').textContent = `${percent}%`;
571
- el.querySelector('.fill').style.width = `${percent}%`;
572
- el.querySelector('.fill').classList.remove('indeterminate');
573
- }
574
- if (indeterminate) {
575
- el.querySelector('.fill').classList.add('indeterminate');
576
- el.querySelector('.pct').textContent = '';
577
- }
578
- if (detail != null) el.querySelector('.detail').textContent = detail;
579
- }
580
- async function streamNewSession(body) {
581
- const res = await fetch('/api/sessions/new', {
582
- method: 'POST',
583
- headers: { 'Content-Type': 'application/json' },
584
- body: JSON.stringify(body),
585
- });
586
- if (!res.ok && res.headers.get('content-type')?.startsWith('application/json')) {
587
- const j = await res.json();
588
- throw new Error(j.error || `HTTP ${res.status}`);
589
- }
590
- const reader = res.body.getReader();
591
- const decoder = new TextDecoder();
592
- let buf = '';
593
- let final = null;
594
- while (true) {
595
- const { done, value } = await reader.read();
596
- if (done) break;
597
- buf += decoder.decode(value, { stream: true });
598
- const lines = buf.split('\n');
599
- buf = lines.pop();
600
- for (const line of lines) {
601
- if (!line.trim()) continue;
602
- let event;
603
- try { event = JSON.parse(line); } catch { continue; }
604
- handleNewSessionEvent(event);
605
- if (event.type === 'done') final = event;
606
- }
607
- }
608
- if (buf.trim()) {
609
- try {
610
- const event = JSON.parse(buf);
611
- handleNewSessionEvent(event);
612
- if (event.type === 'done') final = event;
613
- } catch {}
614
- }
615
- return final || { success: false, error: 'stream ended unexpectedly' };
616
- }
617
- function handleNewSessionEvent(ev) {
618
- switch (ev.type) {
619
- case 'workspace':
620
- $('#newSessionResult').textContent =
621
- `workspace: ${ev.workspace.path}${ev.created ? ' · newly created' : ''}`;
622
- break;
623
- case 'clone-start':
624
- setProgress(ev.repo, { phase: 'starting', indeterminate: true });
625
- break;
626
- case 'clone-progress':
627
- setProgress(ev.repo, {
628
- phase: ev.phase,
629
- percent: ev.percent,
630
- detail: ev.detail || (ev.current != null ? `${ev.current}/${ev.total}` : ''),
631
- });
632
- break;
633
- case 'clone-end':
634
- if (ev.ok) {
635
- setProgress(ev.repo, { phase: ev.action || 'done', percent: 100, detail: ev.path || '', state: 'ok' });
636
- } else {
637
- setProgress(ev.repo, { phase: 'error', detail: ev.error, state: 'error' });
638
- }
639
- break;
640
- case 'launched':
641
- $('#newSessionResult').textContent =
642
- `terminal launching · pid ${ev.launched.pid} · ${ev.launched.terminal}`;
643
- break;
644
- }
645
- }
646
-
647
- /* ─────────────────────────────────────────────────────────────
648
- Wiring
649
- ───────────────────────────────────────────────────────────── */
650
-
651
- function wireUp() {
652
- /* sidebar */
653
- $$('.nav-item').forEach((b) => {
654
- b.addEventListener('click', () => selectTab(b.dataset.tab));
655
- });
656
- $('#collapseBtn').addEventListener('click', toggleSidebar);
657
-
658
- /* hash routing */
659
- const hash = location.hash.slice(1);
660
- if (TAB_HEADINGS[hash]) state.activeTab = hash;
661
-
662
- $('#refreshBtn').onclick = () => refreshAll().then(() => toast('refreshed'));
663
-
664
- /* delegated star toggle across all tables */
665
- for (const tableSel of ['#sessionsTable', '#recentTable', '#favoritesTable']) {
666
- $(tableSel).addEventListener('click', (ev) => {
667
- const starBtn = ev.target.closest('button[data-star]');
668
- if (!starBtn) return;
669
- ev.stopPropagation();
670
- const sessionId = starBtn.dataset.star;
671
- const row = starBtn.closest('tr');
672
- toggleFavorite(sessionId, row);
673
- });
674
- }
675
-
676
- /* favorites table delegated actions (focus / resume / continue) */
677
- $('#favoritesTable').addEventListener('click', async (ev) => {
678
- const focusBtn = ev.target.closest('button[data-focus]');
679
- if (focusBtn) {
680
- const sessionId = focusBtn.dataset.focus;
681
- focusBtn.disabled = true;
682
- try {
683
- const r = await api('POST', `/api/sessions/${sessionId}/focus`);
684
- if (r.ok && r.activated) toast(`focused · ${r.windowTitle || sessionId.slice(0, 8)}`);
685
- else toast(`focus blocked or not running`, 'error');
686
- } catch (e) { toast(e.message, 'error'); }
687
- finally { focusBtn.disabled = false; }
688
- return;
689
- }
690
- const resumeBtn = ev.target.closest('button[data-resume], button[data-continue]');
691
- if (!resumeBtn) return;
692
- const sessionId = resumeBtn.dataset.resume || resumeBtn.dataset.continue;
693
- const cwd = resumeBtn.dataset.cwd;
694
- if (!cwd) return toast('no cwd for this favorite', 'error');
695
- resumeBtn.disabled = true;
696
- try {
697
- await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
698
- toast(`opening wt · ${sessionId.slice(0, 8)}…`);
699
- } catch (e) { toast(e.message, 'error'); }
700
- finally { resumeBtn.disabled = false; }
701
- });
702
-
703
- /* inline finder button on Sessions tab — same handler as the sidebar one */
704
- const inlineFinder = $('#finderInlineBtn');
705
- if (inlineFinder) {
706
- inlineFinder.onclick = () => $('#finderBtn').click();
707
- }
708
-
709
- /* recent pagination */
710
- $('#recentPrevBtn').onclick = () => {
711
- state.recentOffset = Math.max(0, state.recentOffset - state.recentLimit);
712
- loadRecent().catch(() => {});
713
- };
714
- $('#recentNextBtn').onclick = () => {
715
- state.recentOffset = state.recentOffset + state.recentLimit;
716
- loadRecent().catch(() => {});
717
- };
718
- $('#recentPageSize').onchange = (e) => {
719
- state.recentLimit = Math.max(1, Number(e.target.value) || 15);
720
- state.recentOffset = 0;
721
- loadRecent().catch(() => {});
722
- };
723
-
724
- /* live sessions actions */
725
- $('#sessionsTable').addEventListener('click', async (ev) => {
726
- if (ev.target.closest('button[data-star]')) return;
727
- const focusBtn = ev.target.closest('button[data-focus]');
728
- if (focusBtn) {
729
- const sessionId = focusBtn.dataset.focus;
730
- focusBtn.disabled = true;
731
- try {
732
- const r = await api('POST', `/api/sessions/${sessionId}/focus`);
733
- if (r.ok && r.activated) toast(`focused · ${r.windowTitle || sessionId.slice(0, 8)}`);
734
- else if (r.ok) toast(`window found, focus blocked (${r.windowProcess})`, 'error');
735
- else toast(`no window for pid · ${(r.chain || []).map((c) => c.name).join('→')}`, 'error');
736
- } catch (e) { toast(e.message, 'error'); }
737
- finally { focusBtn.disabled = false; }
738
- return;
739
- }
740
- const resumeBtn = ev.target.closest('button[data-resume]');
741
- if (!resumeBtn) return;
742
- const sessionId = resumeBtn.dataset.resume;
743
- const cwd = resumeBtn.dataset.cwd;
744
- resumeBtn.disabled = true;
745
- try {
746
- await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
747
- toast(`opening wt · ${sessionId.slice(0, 8)}…`);
748
- } catch (e) { toast(e.message, 'error'); }
749
- finally { resumeBtn.disabled = false; }
750
- });
751
-
752
- /* recent continue */
753
- $('#recentTable').addEventListener('click', async (ev) => {
754
- if (ev.target.closest('button[data-star]')) return;
755
- const btn = ev.target.closest('button[data-continue]');
756
- if (!btn) return;
757
- const sessionId = btn.dataset.continue;
758
- const cwd = btn.dataset.cwd;
759
- btn.disabled = true;
760
- try {
761
- await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
762
- toast(`continuing · ${sessionId.slice(0, 8)}…`);
763
- setTimeout(() => loadSessions().catch(() => {}), 3000);
764
- setTimeout(() => loadRecent().catch(() => {}), 4000);
765
- } catch (e) { toast(e.message, 'error'); }
766
- finally { btn.disabled = false; }
767
- });
768
-
769
- /* finder */
770
- $('#finderBtn').onclick = async () => {
771
- try {
772
- await api('POST', '/api/sessions/finder');
773
- toast('finder session launching in a new wt window');
774
- } catch (e) { toast(e.message, 'error'); }
775
- };
776
-
777
- /* snapshot */
778
- $('#snapshotSaveBtn').onclick = async () => {
779
- try {
780
- const r = await api('POST', '/api/snapshot');
781
- state.snapshot = r.snapshot;
782
- const h = await api('GET', '/api/snapshot/history');
783
- state.history = h.history;
784
- renderSnapshot();
785
- toast(`saved · ${r.snapshot.sessions.length} session(s)`);
786
- } catch (e) { toast(e.message, 'error'); }
787
- };
788
- $('#snapshotRestoreBtn').onclick = async () => {
789
- const snap = state.snapshot;
790
- if (!snap || !snap.sessions.length) return toast('no sessions in snapshot', 'error');
791
- if (!confirm(`Restore ${snap.sessions.length} session(s)? Each opens a new wt window.`)) return;
792
- try {
793
- const r = await api('POST', '/api/snapshot/restore');
794
- toast(`launched ${r.restored.launched.length} / ${r.count}`);
795
- } catch (e) { toast(e.message, 'error'); }
796
- };
797
- $('#historyRestoreBtn').onclick = async () => {
798
- const file = $('#historySelect').value;
799
- if (!file) return toast('pick a history snapshot first', 'error');
800
- if (!confirm(`Restore from ${file}?`)) return;
801
- try {
802
- const r = await api('POST', '/api/snapshot/restore', { file });
803
- toast(`launched ${r.restored.launched.length} / ${r.count}`);
804
- } catch (e) { toast(e.message, 'error'); }
805
- };
806
-
807
- /* new session */
808
- $('#newSessionBtn').onclick = async () => {
809
- const repos = $$('#repoPicker input:checked').map((i) => i.dataset.repo);
810
- if (repos.length === 0) return toast('select at least one repo', 'error');
811
- const workspace = $('#workspaceSelect').value || undefined;
812
- const btn = $('#newSessionBtn');
813
- btn.disabled = true;
814
- $('#newSessionResult').textContent = '';
815
- resetProgress(repos);
816
- try {
817
- const result = await streamNewSession({ repos, workspace });
818
- if (result.success) {
819
- const ws = result.workspace;
820
- const summary = (result.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
821
- $('#newSessionResult').textContent =
822
- `launched in ${ws.path}${result.created ? ' · newly created' : ''} — ${summary}`;
823
- toast(`launched · ${ws.name}`);
824
- } else {
825
- $('#newSessionResult').textContent = `error: ${result.error}`;
826
- toast(result.error || 'new session failed', 'error');
827
- }
828
- await loadWorkspaces();
829
- } catch (e) {
830
- $('#newSessionResult').textContent = `error: ${e.message}`;
831
- toast(e.message, 'error');
832
- } finally { btn.disabled = false; }
833
- };
834
-
835
- /* config save */
836
- $('#saveConfigBtn').onclick = async () => {
837
- const next = readConfigFromForm();
838
- try {
839
- const cfg = await api('PUT', '/api/config', next);
840
- state.config = cfg;
841
- renderConfig();
842
- renderRepoPicker();
843
- renderHeaderStatus();
844
- $('#configSavedAt').textContent = `saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`;
845
- toast('config saved');
846
- await loadWorkspaces();
847
- } catch (e) { toast(e.message, 'error'); }
848
- };
849
- $('#addRepoBtn').onclick = () => {
850
- state.config.repos.push({ name: '', url: '', defaultSelected: false });
851
- renderConfig();
852
- };
853
- $('#reposTable').addEventListener('click', (ev) => {
854
- const rm = ev.target.closest('button[data-remove-repo]');
855
- if (!rm) return;
856
- const idx = Number(rm.dataset.removeRepo);
857
- state.config.repos.splice(idx, 1);
858
- renderConfig();
859
- });
860
- }
861
-
862
- function startAutoRefresh() {
863
- if (state.autoTimer) clearInterval(state.autoTimer);
864
- state.autoTimer = setInterval(() => {
865
- loadSessions().catch(() => {});
866
- loadRecent().catch(() => {});
867
- loadSnapshot().catch(() => {});
868
- }, 5000);
869
- }
870
-
871
- // Re-render favorites when sessions update so live status of favorited rows refreshes.
872
- function reRenderFavoritesIfNeeded() {
873
- if (Object.keys(state.favorites).length === 0) return;
874
- renderFavorites();
875
- }
876
-
877
- /* ─────────────────────────────────────────────────────────────
878
- Boot
879
- ───────────────────────────────────────────────────────────── */
880
-
881
- (async () => {
882
- restoreSidebar();
883
- wireUp();
884
- try {
885
- await loadConfig();
886
- await refreshAll();
887
- selectTab(state.activeTab);
888
- startAutoRefresh();
889
- tickClock();
890
- state.clockTimer = setInterval(tickClock, 1000);
891
- } catch (e) {
892
- toast('initial load failed · ' + e.message, 'error');
893
- }
894
- })();