@bakapiano/ccsm 0.4.0 → 0.6.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/public/app.js CHANGED
@@ -1,19 +1,48 @@
1
1
  'use strict';
2
2
 
3
+ /* ─────────────────────────────────────────────────────────────
4
+ ccsm · frontend · v0.6 (light sidebar)
5
+ ───────────────────────────────────────────────────────────── */
6
+
3
7
  const $ = (sel) => document.querySelector(sel);
4
8
  const $$ = (sel) => Array.from(document.querySelectorAll(sel));
5
9
 
6
10
  const state = {
7
11
  config: null,
12
+ terminals: [],
8
13
  sessions: [],
14
+ recent: [],
15
+ recentTotal: 0,
16
+ recentOffset: 0,
17
+ recentLimit: 10,
18
+ sessionsOffset: 0,
19
+ sessionsLimit: 10,
20
+ favoritesOffset: 0,
21
+ favoritesLimit: 10,
22
+ cardFolded: { favorites: false, sessions: false, recent: false },
23
+ configDirty: false,
24
+ serverHealth: { state: 'connecting' },
25
+ favorites: {}, // { sessionId: { sessionId, cwd, title, gitBranch, addedAt, label } }
26
+ labels: {}, // { sessionId: customLabel } — user-defined title overrides
9
27
  workspaces: [],
10
28
  snapshot: null,
11
29
  history: [],
12
30
  autoTimer: null,
31
+ clockTimer: null,
32
+ activeTab: 'sessions',
33
+ // Tables that have already had their first render — used to suppress the
34
+ // row stagger animation on subsequent re-renders so 5s auto-refresh
35
+ // doesn't strobe.
36
+ renderedTables: new Set(),
13
37
  };
14
38
 
15
- // ---- API helpers ----
39
+ const TAB_HEADINGS = {
40
+ sessions: { title: 'Sessions', subtitle: 'Live and recently-closed Claude Code sessions on this machine.' },
41
+ launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace, or restore from snapshot.' },
42
+ configure: { title: 'Configure', subtitle: 'Persisted to ~/.ccsm/config.json.' },
43
+ };
16
44
 
45
+ /* ── API ── */
17
46
  async function api(method, url, body) {
18
47
  const opts = { method, headers: { 'Content-Type': 'application/json' } };
19
48
  if (body !== undefined) opts.body = JSON.stringify(body);
@@ -25,159 +54,444 @@ async function api(method, url, body) {
25
54
  return json;
26
55
  }
27
56
 
28
- // ---- toast ----
29
-
57
+ /* ── toast ── */
30
58
  const toastEl = $('#toast');
31
59
  let toastT;
32
60
  function toast(msg, kind = 'ok') {
33
61
  toastEl.textContent = msg;
34
62
  toastEl.className = `toast show ${kind}`;
35
63
  clearTimeout(toastT);
36
- toastT = setTimeout(() => toastEl.classList.remove('show'), 3000);
64
+ toastT = setTimeout(() => toastEl.classList.remove('show'), 3200);
37
65
  }
38
66
 
39
- // ---- formatting ----
40
-
67
+ /* ── fmt ── */
41
68
  function fmtTime(ms) {
42
69
  if (!ms) return '—';
43
- const d = new Date(ms);
44
- return d.toLocaleString(undefined, { hour12: false });
70
+ return new Date(ms).toLocaleString(undefined, { hour12: false });
45
71
  }
46
72
  function fmtAgo(ms) {
47
73
  if (!ms) return '—';
48
74
  const sec = Math.floor((Date.now() - ms) / 1000);
49
- if (sec < 60) return `${sec}s ago`;
50
- if (sec < 3600) return `${Math.floor(sec/60)}m ago`;
51
- if (sec < 86400) return `${Math.floor(sec/3600)}h ago`;
52
- return `${Math.floor(sec/86400)}d ago`;
75
+ if (sec < 60) return `${sec}s`;
76
+ if (sec < 3600) return `${Math.floor(sec / 60)}m`;
77
+ if (sec < 86400) return `${Math.floor(sec / 3600)}h`;
78
+ return `${Math.floor(sec / 86400)}d`;
53
79
  }
54
80
  function escapeHtml(s) {
55
81
  return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
56
- '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;',
82
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
57
83
  }[c]));
58
84
  }
59
85
 
60
- // ---- sessions render ----
86
+ /* Mark a table as already-rendered so animations don't replay on
87
+ subsequent updates. Call after the first innerHTML population. */
88
+ /* Pagination footer helper — sets visibility, page numbers, button states.
89
+ Caller wires up the click/change handlers separately (once). */
90
+ function setPaginationFooter({ footerId, prevId, nextId, pageNumId, pageTotalId, totalId, total, offset, limit }) {
91
+ const footer = $(`#${footerId}`);
92
+ if (!footer) return;
93
+ if (total <= limit) {
94
+ footer.hidden = true;
95
+ return;
96
+ }
97
+ footer.hidden = false;
98
+ const pageNum = Math.floor(offset / limit) + 1;
99
+ const pageTotal = Math.max(1, Math.ceil(total / limit));
100
+ $(`#${pageNumId}`).textContent = pageNum;
101
+ $(`#${pageTotalId}`).textContent = pageTotal;
102
+ $(`#${totalId}`).textContent = total;
103
+ $(`#${prevId}`).disabled = offset === 0;
104
+ $(`#${nextId}`).disabled = offset + limit >= total;
105
+ }
106
+
107
+ function markRendered(tableId) {
108
+ const tb = document.querySelector(`#${tableId} tbody`);
109
+ if (!tb) return;
110
+ if (state.renderedTables.has(tableId)) {
111
+ tb.classList.add('no-anim');
112
+ } else {
113
+ state.renderedTables.add(tableId);
114
+ // first render: animation runs. We schedule no-anim for next paint
115
+ // so the very next re-render doesn't restage.
116
+ requestAnimationFrame(() => {
117
+ requestAnimationFrame(() => tb.classList.add('no-anim'));
118
+ });
119
+ }
120
+ }
121
+
122
+ const STAR_SVG_OUTLINE =
123
+ `<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">
124
+ <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"/>
125
+ </svg>`;
126
+ const STAR_SVG_FILLED =
127
+ `<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">
128
+ <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"/>
129
+ </svg>`;
130
+
131
+ function starButtonHtml(sessionId, isFav) {
132
+ 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>`;
133
+ }
134
+
135
+ const PENCIL_SVG =
136
+ `<svg class="pencil-icon" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
137
+ <path d="M12 20h9"/>
138
+ <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
139
+ </svg>`;
140
+ function renameButtonHtml(sessionId, hasLabel) {
141
+ return `<button class="rename-btn ${hasLabel ? 'has-label' : ''}" data-rename="${escapeHtml(sessionId)}" title="${hasLabel ? 'rename · custom label set' : 'rename'}" aria-label="rename">${PENCIL_SVG}</button>`;
142
+ }
143
+
144
+ // Compose the displayed title: user override (label) takes precedence over
145
+ // claude's ai-title. Falls back to "(no title)" if both empty.
146
+ function displayTitle(sessionId, fallback) {
147
+ return state.labels[sessionId] || fallback || '(no title)';
148
+ }
149
+
150
+ /* ─────────────────────────────────────────────────────────────
151
+ Sidebar — tabs + collapse
152
+ ───────────────────────────────────────────────────────────── */
153
+
154
+ function selectTab(name) {
155
+ if (!TAB_HEADINGS[name]) name = 'sessions';
156
+ state.activeTab = name;
157
+ $$('.nav-item').forEach((b) => {
158
+ b.setAttribute('aria-selected', b.dataset.tab === name ? 'true' : 'false');
159
+ });
160
+ $$('.tab-panel').forEach((p) => {
161
+ if (p.dataset.panel === name) p.setAttribute('data-active', '');
162
+ else p.removeAttribute('data-active');
163
+ });
164
+ const h = TAB_HEADINGS[name];
165
+ $('#pageTitle').textContent = h.title;
166
+ $('#pageSubtitle').textContent = h.subtitle;
167
+ if (location.hash !== `#${name}`) history.replaceState(null, '', `#${name}`);
168
+ }
169
+
170
+ function toggleSidebar() {
171
+ const sb = $('#sidebar');
172
+ const collapsed = sb.getAttribute('data-collapsed') === 'true';
173
+ sb.setAttribute('data-collapsed', collapsed ? 'false' : 'true');
174
+ localStorage.setItem('ccsm.sidebar-collapsed', collapsed ? 'false' : 'true');
175
+ }
176
+ function restoreSidebar() {
177
+ const v = localStorage.getItem('ccsm.sidebar-collapsed');
178
+ if (v === 'true') $('#sidebar').setAttribute('data-collapsed', 'true');
179
+ }
180
+
181
+ /* ── Config dirty state ── */
182
+ function setConfigDirty(dirty) {
183
+ state.configDirty = dirty;
184
+ const banner = document.getElementById('configDirtyBanner');
185
+ if (banner) banner.hidden = !dirty;
186
+ const nav = document.querySelector('.nav-item[data-tab="configure"]');
187
+ if (nav) nav.classList.toggle('has-changes', dirty);
188
+ const saveBtn = document.getElementById('saveConfigBtn');
189
+ if (saveBtn) saveBtn.classList.toggle('is-dirty', dirty);
190
+ }
191
+
192
+ /* ── Card fold ── */
193
+ function toggleCardFold(key) {
194
+ const card = document.querySelector(`.card[data-fold-key="${key}"]`);
195
+ if (!card) return;
196
+ const next = !state.cardFolded[key];
197
+ state.cardFolded[key] = next;
198
+ if (next) card.setAttribute('data-collapsed', '');
199
+ else card.removeAttribute('data-collapsed');
200
+ localStorage.setItem(`ccsm.fold.${key}`, next ? '1' : '0');
201
+ }
202
+ function restoreCardFolds() {
203
+ for (const key of ['favorites', 'sessions', 'recent']) {
204
+ const v = localStorage.getItem(`ccsm.fold.${key}`);
205
+ if (v === '1') {
206
+ state.cardFolded[key] = true;
207
+ const card = document.querySelector(`.card[data-fold-key="${key}"]`);
208
+ if (card) card.setAttribute('data-collapsed', '');
209
+ }
210
+ }
211
+ }
212
+
213
+ /* ─────────────────────────────────────────────────────────────
214
+ Render: sessions (live)
215
+ ───────────────────────────────────────────────────────────── */
61
216
 
62
217
  function renderSessions() {
63
218
  const tb = $('#sessionsTable tbody');
64
219
  tb.innerHTML = '';
65
- for (const s of state.sessions) {
220
+ // clamp offset if data shrunk
221
+ if (state.sessionsOffset >= state.sessions.length) {
222
+ state.sessionsOffset = Math.max(0, Math.floor((state.sessions.length - 1) / state.sessionsLimit) * state.sessionsLimit);
223
+ }
224
+ const slice = state.sessions.slice(state.sessionsOffset, state.sessionsOffset + state.sessionsLimit);
225
+ for (const s of slice) {
226
+ const isFav = !!state.favorites[s.sessionId];
227
+ const hasLabel = !!state.labels[s.sessionId];
228
+ const shown = displayTitle(s.sessionId, s.title);
229
+ const tooltip = hasLabel ? `${shown}\n(original: ${s.title || '—'})` : shown;
66
230
  const tr = document.createElement('tr');
67
231
  tr.innerHTML = `
68
- <td><span class="status-dot ${escapeHtml(s.status)}" title="${escapeHtml(s.status)}"></span></td>
69
- <td><div class="ellipsis" title="${escapeHtml(s.title || '')}">${escapeHtml(s.title || '(no title)')}</div>
70
- <div class="mono small" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0,8))}…</div></td>
71
- <td><div class="ellipsis mono" title="${escapeHtml(s.cwd)}">${escapeHtml(s.cwd)}</div></td>
72
- <td title="${escapeHtml(fmtTime(s.updatedAt))}">${escapeHtml(fmtAgo(s.updatedAt))}</td>
73
- <td title="${escapeHtml(fmtTime(s.startedAt))}">${escapeHtml(fmtAgo(s.startedAt))}</td>
74
- <td class="mono">${escapeHtml(String(s.pid))}</td>
75
- <td style="text-align:right; white-space:nowrap;">
76
- <button class="btn small btn-primary" data-focus="${escapeHtml(s.sessionId)}" title="bring the wt window already hosting this session to the foreground">focus</button>
77
- <button class="btn small" data-resume="${escapeHtml(s.sessionId)}" data-cwd="${escapeHtml(s.cwd)}" title="open a NEW wt window with claude --resume">resume new</button>
232
+ <td><span class="status-mark ${escapeHtml(s.status)}" title="${escapeHtml(s.status)}"></span></td>
233
+ <td>
234
+ <div class="title-cell">
235
+ <div class="title-row">
236
+ <span class="primary" title="${escapeHtml(tooltip)}">${escapeHtml(shown)}</span>
237
+ ${renameButtonHtml(s.sessionId, hasLabel)}
238
+ ${starButtonHtml(s.sessionId, isFav)}
239
+ </div>
240
+ <div class="secondary" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0, 8))}${s.version ? ' · ' + escapeHtml(s.version) : ''}</div>
241
+ </div>
242
+ </td>
243
+ <td><div class="path-cell" title="${escapeHtml(s.cwd)}">${escapeHtml(s.cwd)}</div></td>
244
+ <td class="num" title="${escapeHtml(fmtTime(s.updatedAt))}">${escapeHtml(fmtAgo(s.updatedAt))}</td>
245
+ <td class="num" title="${escapeHtml(fmtTime(s.startedAt))}">${escapeHtml(fmtAgo(s.startedAt))}</td>
246
+ <td class="num">${escapeHtml(String(s.pid))}</td>
247
+ <td>
248
+ <div class="row-actions">
249
+ <button class="action small primary" data-focus="${escapeHtml(s.sessionId)}" title="raise the wt window already running this session">Focus</button>
250
+ <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>
251
+ </div>
78
252
  </td>
79
253
  `;
254
+ tr.dataset.cwd = s.cwd;
255
+ tr.dataset.title = s.title || '';
80
256
  tb.appendChild(tr);
81
257
  }
82
- $('#sessionsMeta').textContent =
83
- state.sessions.length ? `${state.sessions.length} live · last refresh ${new Date().toLocaleTimeString()}` : 'no live sessions';
258
+ $('#sessionsEmpty').hidden = state.sessions.length > 0;
259
+ const ts = new Date().toLocaleTimeString(undefined, { hour12: false });
260
+ $('#sessionsMeta').textContent = state.sessions.length
261
+ ? `${state.sessions.length} live · refreshed ${ts}`
262
+ : 'no live sessions';
263
+ $('#navCount-sessions').textContent = state.sessions.length;
264
+ setPaginationFooter({
265
+ footerId: 'sessionsPagination', prevId: 'sessPrevBtn', nextId: 'sessNextBtn',
266
+ pageNumId: 'sessPageNum', pageTotalId: 'sessPageTotal', totalId: 'sessTotal',
267
+ total: state.sessions.length, offset: state.sessionsOffset, limit: state.sessionsLimit,
268
+ });
269
+ markRendered('sessionsTable');
84
270
  }
85
271
 
272
+ /* ─────────────────────────────────────────────────────────────
273
+ Render: recently closed
274
+ ───────────────────────────────────────────────────────────── */
275
+
86
276
  function renderRecent() {
87
277
  const tb = $('#recentTable tbody');
88
278
  tb.innerHTML = '';
89
279
  const recent = state.recent || [];
90
280
  for (const s of recent) {
281
+ const isFav = !!state.favorites[s.sessionId];
282
+ const hasLabel = !!state.labels[s.sessionId];
283
+ const shown = displayTitle(s.sessionId, s.title);
284
+ const tooltip = hasLabel ? `${shown}\n(original: ${s.title || '—'})` : shown;
91
285
  const tr = document.createElement('tr');
92
286
  tr.innerHTML = `
93
- <td><div class="ellipsis" title="${escapeHtml(s.title || '')}">${escapeHtml(s.title || '(no title)')}</div>
94
- <div class="mono small" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0,8))}…</div></td>
95
- <td><div class="ellipsis mono" title="${escapeHtml(s.cwd || '')}">${escapeHtml(s.cwd || '')}</div></td>
96
- <td class="mono small">${escapeHtml(s.gitBranch || '')}</td>
97
- <td title="${escapeHtml(fmtTime(s.updatedAt))}">${escapeHtml(fmtAgo(s.updatedAt))}</td>
98
- <td title="${escapeHtml(fmtTime(s.startedAt))}">${escapeHtml(fmtAgo(s.startedAt))}</td>
99
- <td style="text-align:right;">
100
- <button class="btn small btn-primary" data-continue="${escapeHtml(s.sessionId)}" data-cwd="${escapeHtml(s.cwd)}" title="open a new wt window with claude --resume">continue</button>
287
+ <td>
288
+ <div class="title-cell">
289
+ <div class="title-row">
290
+ <span class="primary" title="${escapeHtml(tooltip)}">${escapeHtml(shown)}</span>
291
+ ${renameButtonHtml(s.sessionId, hasLabel)}
292
+ ${starButtonHtml(s.sessionId, isFav)}
293
+ </div>
294
+ <div class="secondary" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0, 8))}</div>
295
+ </div>
296
+ </td>
297
+ <td><div class="path-cell" title="${escapeHtml(s.cwd || '')}">${escapeHtml(s.cwd || '')}</div></td>
298
+ <td>${s.gitBranch ? `<span class="branch-tag">${escapeHtml(s.gitBranch)}</span>` : '<span class="muted-text">—</span>'}</td>
299
+ <td class="num" title="${escapeHtml(fmtTime(s.updatedAt))}">${escapeHtml(fmtAgo(s.updatedAt))}</td>
300
+ <td class="num" title="${escapeHtml(fmtTime(s.startedAt))}">${escapeHtml(fmtAgo(s.startedAt))}</td>
301
+ <td>
302
+ <div class="row-actions">
303
+ <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>
304
+ </div>
101
305
  </td>
102
306
  `;
307
+ tr.dataset.cwd = s.cwd || '';
308
+ tr.dataset.title = s.title || '';
309
+ tr.dataset.gitBranch = s.gitBranch || '';
103
310
  tb.appendChild(tr);
104
311
  }
105
- $('#recentMeta').textContent =
106
- recent.length ? `${recent.length} recent · last refresh ${new Date().toLocaleTimeString()}` : 'no recent sessions';
312
+ $('#recentEmpty').hidden = recent.length > 0;
313
+ $('#recentMeta').textContent = state.recentTotal
314
+ ? `${state.recentTotal} total · sorted by jsonl mtime, excluding live`
315
+ : 'no recent sessions';
316
+ setPaginationFooter({
317
+ footerId: 'recentPagination', prevId: 'recentPrevBtn', nextId: 'recentNextBtn',
318
+ pageNumId: 'recentPageNum', pageTotalId: 'recentPageTotal', totalId: 'recentTotal',
319
+ total: state.recentTotal, offset: state.recentOffset, limit: state.recentLimit,
320
+ });
321
+ markRendered('recentTable');
107
322
  }
108
323
 
109
- // ---- snapshot render ----
324
+ /* ─────────────────────────────────────────────────────────────
325
+ Render: favorites
326
+ ───────────────────────────────────────────────────────────── */
327
+ function renderFavorites() {
328
+ const tb = $('#favoritesTable tbody');
329
+ tb.innerHTML = '';
330
+ const fullList = Object.values(state.favorites).sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
331
+ if (state.favoritesOffset >= fullList.length) {
332
+ state.favoritesOffset = Math.max(0, Math.floor((fullList.length - 1) / state.favoritesLimit) * state.favoritesLimit);
333
+ }
334
+ const list = fullList.slice(state.favoritesOffset, state.favoritesOffset + state.favoritesLimit);
335
+ for (const f of list) {
336
+ const liveMatch = state.sessions.find((s) => s.sessionId === f.sessionId);
337
+ const title = liveMatch?.title || f.title;
338
+ const cwd = liveMatch?.cwd || f.cwd;
339
+ const branch = f.gitBranch;
340
+ const actions = liveMatch
341
+ ? `<button class="action small primary" data-focus="${escapeHtml(f.sessionId)}" title="raise the wt window">Focus</button>
342
+ <button class="action small" data-resume="${escapeHtml(f.sessionId)}" data-cwd="${escapeHtml(cwd)}" title="claude --resume in a fresh wt window">Resume new ↗</button>`
343
+ : `<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>`;
344
+ const hasLabel = !!state.labels[f.sessionId];
345
+ const shown = displayTitle(f.sessionId, title);
346
+ const tooltip = hasLabel ? `${shown}\n(original: ${title || '—'})` : shown;
347
+ const tr = document.createElement('tr');
348
+ tr.innerHTML = `
349
+ <td>
350
+ <div class="title-cell">
351
+ <div class="title-row">
352
+ <span class="primary" title="${escapeHtml(tooltip)}">${escapeHtml(shown)}</span>
353
+ ${renameButtonHtml(f.sessionId, hasLabel)}
354
+ ${starButtonHtml(f.sessionId, true)}
355
+ </div>
356
+ <div class="secondary" title="${escapeHtml(f.sessionId)}">
357
+ ${escapeHtml(f.sessionId.slice(0, 8))}${liveMatch ? ` · <span style="color:var(--green);">live</span>` : ''}
358
+ </div>
359
+ </div>
360
+ </td>
361
+ <td><div class="path-cell" title="${escapeHtml(cwd || '')}">${escapeHtml(cwd || '')}</div></td>
362
+ <td>${branch ? `<span class="branch-tag">${escapeHtml(branch)}</span>` : '<span class="muted-text">—</span>'}</td>
363
+ <td class="num" title="${escapeHtml(fmtTime(f.addedAt))}">${escapeHtml(fmtAgo(f.addedAt))}</td>
364
+ <td><div class="row-actions">${actions}</div></td>
365
+ `;
366
+ tr.dataset.cwd = cwd || '';
367
+ tr.dataset.title = title || '';
368
+ tb.appendChild(tr);
369
+ }
370
+ const total = fullList.length;
371
+ $('#favoritesEmpty').style.display = total === 0 ? 'block' : 'none';
372
+ $('#favoritesTable').style.display = total === 0 ? 'none' : 'table';
373
+ $('#favoritesMeta').textContent = total
374
+ ? `${total} pinned`
375
+ : 'click ☆ on any row to pin sessions here';
376
+ setPaginationFooter({
377
+ footerId: 'favoritesPagination', prevId: 'favPrevBtn', nextId: 'favNextBtn',
378
+ pageNumId: 'favPageNum', pageTotalId: 'favPageTotal', totalId: 'favTotal',
379
+ total, offset: state.favoritesOffset, limit: state.favoritesLimit,
380
+ });
381
+ markRendered('favoritesTable');
382
+ }
383
+
384
+ /* ─────────────────────────────────────────────────────────────
385
+ Render: snapshot
386
+ ───────────────────────────────────────────────────────────── */
110
387
 
111
388
  function renderSnapshot() {
112
389
  const snap = state.snapshot;
113
390
  if (!snap) {
114
- $('#snapshotMeta').textContent = 'no snapshot yet';
391
+ $('#snapshotMeta').textContent = 'no snapshot saved yet';
115
392
  $('#snapshotPreview').textContent = '';
116
393
  return;
117
394
  }
118
395
  $('#snapshotMeta').textContent =
119
- `${snap.sessions.length} session(s) taken ${fmtAgo(snap.takenAt)} (${fmtTime(snap.takenAt)})`;
120
- const lines = snap.sessions.map((s) =>
121
- `${(s.title || s.sessionId.slice(0,8)).padEnd(40).slice(0,40)} ${s.cwd}`
122
- );
123
- $('#snapshotPreview').textContent = lines.join('\n');
396
+ `${snap.sessions.length} session(s) · taken ${fmtAgo(snap.takenAt)} ago (${fmtTime(snap.takenAt)})`;
397
+ $('#snapshotPreview').textContent =
398
+ snap.sessions.map((s) =>
399
+ `${(s.title || s.sessionId.slice(0, 8)).padEnd(44).slice(0, 44)} ${s.cwd}`
400
+ ).join('\n');
124
401
 
125
402
  const sel = $('#historySelect');
126
403
  sel.innerHTML = '<option value="">history…</option>' +
127
- state.history.map((h) => `<option value="${escapeHtml(h.file)}">${escapeHtml(h.file.replace('.json',''))}</option>`).join('');
404
+ state.history.map((h) =>
405
+ `<option value="${escapeHtml(h.file)}">${escapeHtml(h.file.replace('.json', ''))}</option>`
406
+ ).join('');
128
407
  }
129
408
 
130
- // ---- workspaces render ----
409
+ /* ─────────────────────────────────────────────────────────────
410
+ Render: workspaces
411
+ ───────────────────────────────────────────────────────────── */
131
412
 
132
413
  function renderWorkspaces() {
133
- const ul = $('#workspaceList');
134
- ul.innerHTML = '';
414
+ const grid = $('#workspaceList');
415
+ grid.innerHTML = '';
135
416
  if (state.workspaces.length === 0) {
136
- ul.innerHTML = '<div class="muted small">no workspaces under workDir yet — first new-session will create one</div>';
417
+ grid.innerHTML = '<div class="empty">No workspaces yet — the first launch will create one.</div>';
137
418
  }
138
419
  for (const w of state.workspaces) {
139
- const repoTags = w.repos.map((r) =>
140
- `<span class="tag ${r.cloned ? 'ok' : ''}">${escapeHtml(r.name)}${r.cloned ? ' ✓' : ''}</span>`
141
- ).join(' ');
420
+ const repos = w.repos.map((r) =>
421
+ `<span class="ws-repo ${r.cloned ? 'cloned' : ''}" title="${escapeHtml(r.url)}">${escapeHtml(r.name)}${r.cloned ? ' ✓' : ''}</span>`
422
+ ).join('');
142
423
  const card = document.createElement('div');
143
424
  card.className = 'workspace-card' + (w.inUse ? ' in-use' : '');
144
425
  card.innerHTML = `
145
- <div>
146
- <div class="name">${escapeHtml(w.name)}
147
- ${w.inUse ? `<span class="tag warn">in use × ${w.sessionsHere.length}</span>` : '<span class="tag ok">free</span>'}
148
- </div>
149
- <div class="repos">${escapeHtml(w.path)}</div>
150
- <div style="margin-top:4px;">${repoTags}</div>
426
+ <div class="ws-head">
427
+ <div class="ws-name">${escapeHtml(w.name)}</div>
428
+ <span class="ws-tag">${w.inUse ? `in use × ${w.sessionsHere.length}` : 'free'}</span>
151
429
  </div>
430
+ <div class="ws-path">${escapeHtml(w.path)}</div>
431
+ <div class="ws-repos">${repos}</div>
152
432
  `;
153
- ul.appendChild(card);
433
+ grid.appendChild(card);
154
434
  }
155
435
 
156
- const sel = $('#workspaceSelect');
157
- sel.innerHTML = '<option value="">(auto — find or create unused)</option>' +
436
+ const opts = '<option value="">auto — find or create unused</option>' +
158
437
  state.workspaces.filter((w) => !w.inUse).map((w) =>
159
438
  `<option value="${escapeHtml(w.name)}">${escapeHtml(w.name)}</option>`
160
439
  ).join('');
440
+ for (const id of ['workspaceSelect', 'modalWorkspaceSelect']) {
441
+ const el = document.getElementById(id);
442
+ if (el) el.innerHTML = opts;
443
+ }
444
+
445
+ if (state.config) $('#workDirDisplay').textContent = state.config.workDir;
161
446
  }
162
447
 
163
- // ---- repo picker render (for "new session") ----
448
+ /* ─────────────────────────────────────────────────────────────
449
+ Render: repo picker
450
+ ───────────────────────────────────────────────────────────── */
164
451
 
165
452
  function renderRepoPicker() {
166
- const root = $('#repoPicker');
167
- root.innerHTML = '';
168
- for (const r of (state.config?.repos || [])) {
169
- const id = `repo_${r.name}`;
170
- const chip = document.createElement('label');
171
- chip.className = 'repo-chip' + (r.defaultSelected ? ' checked' : '');
172
- chip.innerHTML = `<input type="checkbox" id="${id}" data-repo="${escapeHtml(r.name)}" ${r.defaultSelected ? 'checked' : ''}/>${escapeHtml(r.name)}`;
173
- chip.querySelector('input').addEventListener('change', (e) => {
174
- chip.classList.toggle('checked', e.target.checked);
175
- });
176
- root.appendChild(chip);
453
+ const repos = state.config?.repos || [];
454
+ for (const rootId of ['repoPicker', 'modalRepoPicker']) {
455
+ const root = document.getElementById(rootId);
456
+ if (!root) continue;
457
+ if (repos.length === 0) {
458
+ root.innerHTML = '<span class="muted-text">no repos configured · use <strong>+ Add repo</strong> below</span>';
459
+ continue;
460
+ }
461
+ root.innerHTML = '';
462
+ for (const r of repos) {
463
+ const chip = document.createElement('label');
464
+ chip.className = 'chip' + (r.defaultSelected ? ' checked' : '');
465
+ chip.innerHTML = `<input type="checkbox" data-repo="${escapeHtml(r.name)}" ${r.defaultSelected ? 'checked' : ''}/>${escapeHtml(r.name)}`;
466
+ chip.querySelector('input').addEventListener('change', (e) => {
467
+ chip.classList.toggle('checked', e.target.checked);
468
+ });
469
+ root.appendChild(chip);
470
+ }
177
471
  }
178
472
  }
179
473
 
180
- // ---- config form render ----
474
+ /* Renders the inline repos editor inside the modal. Uses state.config.repos
475
+ directly (writes back through the same Configure save). */
476
+ function renderModalReposEditor() {
477
+ const tb = document.querySelector('#modalReposTable tbody');
478
+ if (!tb) return;
479
+ tb.innerHTML = '';
480
+ (state.config?.repos || []).forEach((r, idx) => {
481
+ const tr = document.createElement('tr');
482
+ tr.innerHTML = `
483
+ <td><input type="text" value="${escapeHtml(r.name)}" data-modal-field="name" data-idx="${idx}" /></td>
484
+ <td><input type="text" value="${escapeHtml(r.url)}" data-modal-field="url" data-idx="${idx}" /></td>
485
+ <td class="num"><input type="checkbox" data-modal-field="defaultSelected" data-idx="${idx}" ${r.defaultSelected ? 'checked' : ''} /></td>
486
+ <td><div class="row-actions"><button class="action tiny danger" data-modal-remove="${idx}">Remove</button></div></td>
487
+ `;
488
+ tb.appendChild(tr);
489
+ });
490
+ }
491
+
492
+ /* ─────────────────────────────────────────────────────────────
493
+ Render: config form
494
+ ───────────────────────────────────────────────────────────── */
181
495
 
182
496
  function renderConfig() {
183
497
  if (!state.config) return;
@@ -188,13 +502,16 @@ function renderConfig() {
188
502
  $('#cfgClaudeCommand').value = state.config.claudeCommand || 'claude';
189
503
  $('#cfgCommandShell').value = state.config.commandShell || 'pwsh';
190
504
  $('#cfgAutoFocus').checked = state.config.autoFocusOnLaunch !== false;
505
+ $('#cfgFocusCenter').checked = state.config.focusMovesToCenter === true;
191
506
  $('#cfgBrowserMode').value =
192
507
  state.config.browserMode ||
193
508
  (state.config.autoOpenBrowser === false ? 'none' : 'app');
509
+
194
510
  const termSel = $('#cfgTerminal');
195
511
  termSel.innerHTML = (state.terminals || []).map((t) =>
196
- `<option value="${escapeHtml(t.name)}" ${t.name === state.config.terminal ? 'selected' : ''}>${escapeHtml(t.name)} (${escapeHtml(t.processName)})</option>`
512
+ `<option value="${escapeHtml(t.name)}" ${t.name === state.config.terminal ? 'selected' : ''}>${escapeHtml(t.name)} · ${escapeHtml(t.processName)}</option>`
197
513
  ).join('');
514
+
198
515
  $('#cfgFinderPrompt').value = state.config.finderPrompt || '';
199
516
 
200
517
  const tb = $('#reposTable tbody');
@@ -202,10 +519,10 @@ function renderConfig() {
202
519
  (state.config.repos || []).forEach((r, idx) => {
203
520
  const tr = document.createElement('tr');
204
521
  tr.innerHTML = `
205
- <td><input type="text" value="${escapeHtml(r.name)}" data-field="name" data-idx="${idx}" style="width:140px;" /></td>
206
- <td><input type="text" value="${escapeHtml(r.url)}" data-field="url" data-idx="${idx}" style="width:100%;" /></td>
207
- <td style="text-align:center;"><input type="checkbox" data-field="defaultSelected" data-idx="${idx}" ${r.defaultSelected ? 'checked' : ''} /></td>
208
- <td style="text-align:right;"><button class="btn small btn-danger" data-remove-repo="${idx}">remove</button></td>
522
+ <td><input type="text" value="${escapeHtml(r.name)}" data-field="name" data-idx="${idx}" /></td>
523
+ <td><input type="text" value="${escapeHtml(r.url)}" data-field="url" data-idx="${idx}" /></td>
524
+ <td class="num"><input type="checkbox" data-field="defaultSelected" data-idx="${idx}" ${r.defaultSelected ? 'checked' : ''} /></td>
525
+ <td><div class="row-actions"><button class="action tiny danger" data-remove-repo="${idx}">Remove</button></div></td>
209
526
  `;
210
527
  tb.appendChild(tr);
211
528
  });
@@ -230,26 +547,72 @@ function readConfigFromForm() {
230
547
  terminal: $('#cfgTerminal').value || 'wt',
231
548
  commandShell: $('#cfgCommandShell').value || 'pwsh',
232
549
  autoFocusOnLaunch: $('#cfgAutoFocus').checked,
550
+ focusMovesToCenter: $('#cfgFocusCenter').checked,
233
551
  browserMode: $('#cfgBrowserMode').value || 'app',
234
552
  finderPrompt: $('#cfgFinderPrompt').value,
235
553
  repos,
236
554
  };
237
555
  }
238
556
 
239
- // ---- data fetching ----
557
+ /* ─────────────────────────────────────────────────────────────
558
+ Header + footer status
559
+ ───────────────────────────────────────────────────────────── */
240
560
 
241
- async function loadSessions() {
242
- const r = await api('GET', '/api/sessions');
243
- state.sessions = r.sessions;
244
- renderSessions();
561
+ function renderHeaderStatus() {
562
+ if (!state.config) return;
563
+ $('#hdPort').textContent = String(state.config.port);
564
+ $('#hdTerminal').textContent =
565
+ `${state.config.terminal} · ${state.config.claudeCommand}` +
566
+ (state.config.terminal === 'wt' ? ` (${state.config.commandShell})` : '');
567
+ $('#footWorkDir').textContent = state.config.workDir;
568
+ $('#footData').textContent = '~/.ccsm';
569
+ }
570
+ function tickClock() {
571
+ const t = new Date().toLocaleTimeString(undefined, { hour12: false });
572
+ const el = $('#hdTime');
573
+ if (el) el.textContent = t;
245
574
  }
246
575
 
247
- async function loadRecent() {
248
- const r = await api('GET', '/api/sessions/recent?limit=50');
249
- state.recent = r.recent;
250
- renderRecent();
576
+ /* ── Server health poll ── */
577
+ async function pollHealth() {
578
+ const ctrl = new AbortController();
579
+ const timeout = setTimeout(() => ctrl.abort(), 3000);
580
+ try {
581
+ const r = await fetch('/api/health', { signal: ctrl.signal });
582
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
583
+ const j = await r.json();
584
+ state.serverHealth = { state: 'online', version: j.version, pid: j.pid };
585
+ } catch (e) {
586
+ state.serverHealth = { state: 'offline', error: String(e.message || e) };
587
+ } finally {
588
+ clearTimeout(timeout);
589
+ }
590
+ renderServerStatus();
591
+ }
592
+
593
+ function renderServerStatus() {
594
+ const el = $('#serverStatus');
595
+ if (!el) return;
596
+ const h = state.serverHealth || { state: 'connecting' };
597
+ el.dataset.state = h.state;
598
+ const label = $('#serverStatusLabel');
599
+ if (!label) return;
600
+ if (h.state === 'online') {
601
+ label.textContent = h.version ? `online · v${h.version}` : 'online';
602
+ el.title = `backend ok · pid ${h.pid} · v${h.version}`;
603
+ } else if (h.state === 'offline') {
604
+ label.textContent = 'offline';
605
+ el.title = `backend unreachable — ${h.error || ''}`;
606
+ } else {
607
+ label.textContent = 'connecting…';
608
+ el.title = 'checking backend status';
609
+ }
251
610
  }
252
611
 
612
+ /* ─────────────────────────────────────────────────────────────
613
+ Loaders
614
+ ───────────────────────────────────────────────────────────── */
615
+
253
616
  async function loadConfig() {
254
617
  const [cfg, terminals] = await Promise.all([
255
618
  api('GET', '/api/config'),
@@ -259,10 +622,97 @@ async function loadConfig() {
259
622
  state.terminals = terminals.terminals;
260
623
  renderConfig();
261
624
  renderRepoPicker();
262
- $('#serverInfo').textContent =
263
- `port ${state.config.port} · workDir ${state.config.workDir} · terminal ${state.config.terminal} · ${state.config.claudeCommand}`;
625
+ renderHeaderStatus();
626
+ }
627
+ async function loadSessions() {
628
+ const r = await api('GET', '/api/sessions');
629
+ state.sessions = r.sessions;
630
+ renderSessions();
631
+ }
632
+ async function loadRecent() {
633
+ const r = await api('GET', `/api/sessions/recent?limit=${state.recentLimit}&offset=${state.recentOffset}`);
634
+ state.recent = r.recent;
635
+ state.recentTotal = r.total || 0;
636
+ state.recentLimit = r.limit || state.recentLimit;
637
+ state.recentOffset = r.offset || 0;
638
+ renderRecent();
264
639
  }
265
640
 
641
+ async function loadFavorites() {
642
+ try {
643
+ const r = await api('GET', '/api/favorites');
644
+ const map = {};
645
+ for (const f of r.favorites || []) map[f.sessionId] = f;
646
+ state.favorites = map;
647
+ renderFavorites();
648
+ } catch (e) { /* ignore */ }
649
+ }
650
+
651
+ async function loadLabels() {
652
+ try {
653
+ const r = await api('GET', '/api/labels');
654
+ state.labels = r.labels || {};
655
+ } catch (e) { /* ignore */ }
656
+ }
657
+
658
+ async function renameSession(sessionId, currentLabel) {
659
+ const next = await ccsmPrompt('Rename session', currentLabel || '', {
660
+ title: 'Rename session',
661
+ placeholder: 'leave empty to clear the label',
662
+ okLabel: 'Save',
663
+ });
664
+ if (next === null) return; // user cancelled
665
+ const trimmed = next.trim();
666
+ // optimistic
667
+ const prev = state.labels[sessionId];
668
+ if (trimmed) state.labels[sessionId] = trimmed;
669
+ else delete state.labels[sessionId];
670
+ renderSessions();
671
+ renderRecent();
672
+ renderFavorites();
673
+ try {
674
+ if (trimmed) {
675
+ await api('PUT', `/api/labels/${sessionId}`, { label: trimmed });
676
+ toast(`renamed · ${sessionId.slice(0, 8)}`);
677
+ } else {
678
+ await api('DELETE', `/api/labels/${sessionId}`);
679
+ toast(`cleared label · ${sessionId.slice(0, 8)}`);
680
+ }
681
+ } catch (e) {
682
+ // rollback
683
+ if (prev !== undefined) state.labels[sessionId] = prev;
684
+ else delete state.labels[sessionId];
685
+ renderSessions();
686
+ renderRecent();
687
+ renderFavorites();
688
+ toast('rename failed: ' + e.message, 'error');
689
+ }
690
+ }
691
+
692
+ async function toggleFavorite(sessionId, sourceRow) {
693
+ const wasFav = !!state.favorites[sessionId];
694
+ if (wasFav) {
695
+ // optimistic remove
696
+ delete state.favorites[sessionId];
697
+ renderFavorites();
698
+ renderSessions();
699
+ renderRecent();
700
+ try { await api('DELETE', `/api/favorites/${sessionId}`); }
701
+ catch (e) { toast('unfavorite failed: ' + e.message, 'error'); }
702
+ } else {
703
+ // optimistic add — snapshot row's data so the favorite is meaningful
704
+ // even when the session later moves out of live/recent
705
+ const cwd = sourceRow?.dataset?.cwd || '';
706
+ const title = sourceRow?.dataset?.title || '';
707
+ const gitBranch = sourceRow?.dataset?.gitBranch || '';
708
+ state.favorites[sessionId] = { sessionId, cwd, title, gitBranch, addedAt: Date.now() };
709
+ renderFavorites();
710
+ renderSessions();
711
+ renderRecent();
712
+ try { await api('POST', `/api/favorites/${sessionId}`, { cwd, title, gitBranch }); }
713
+ catch (e) { toast('favorite failed: ' + e.message, 'error'); }
714
+ }
715
+ }
266
716
  async function loadSnapshot() {
267
717
  const r = await api('GET', '/api/snapshot');
268
718
  state.snapshot = r.snapshot;
@@ -270,64 +720,402 @@ async function loadSnapshot() {
270
720
  state.history = h.history;
271
721
  renderSnapshot();
272
722
  }
273
-
274
723
  async function loadWorkspaces() {
275
724
  const r = await api('GET', '/api/workspaces');
276
725
  state.workspaces = r.workspaces;
277
726
  renderWorkspaces();
278
727
  }
279
-
280
728
  async function refreshAll() {
281
- await Promise.all([loadSessions(), loadRecent(), loadSnapshot(), loadWorkspaces()]);
729
+ await Promise.all([loadSessions(), loadRecent(), loadSnapshot(), loadWorkspaces(), loadFavorites(), loadLabels()]);
730
+ }
731
+
732
+ /* ─────────────────────────────────────────────────────────────
733
+ Clone progress stream (NDJSON)
734
+ ───────────────────────────────────────────────────────────── */
735
+
736
+ function resetProgress(repoNames, rootId = 'newSessionProgress') {
737
+ const root = document.getElementById(rootId);
738
+ if (!root) return;
739
+ root.innerHTML = '';
740
+ for (const r of repoNames) {
741
+ const el = document.createElement('div');
742
+ el.className = 'progress-item';
743
+ el.dataset.repo = r;
744
+ el.innerHTML = `
745
+ <div class="head">
746
+ <span class="name">${escapeHtml(r)}</span>
747
+ <span class="phase">queued</span>
748
+ <span class="pct"></span>
749
+ </div>
750
+ <div class="progress-bar"><div class="fill"></div></div>
751
+ <div class="detail"></div>
752
+ `;
753
+ root.appendChild(el);
754
+ }
755
+ }
756
+ function progressItem(repo, rootId = 'newSessionProgress') {
757
+ return document.querySelector(`#${rootId} .progress-item[data-repo="${CSS.escape(repo)}"]`);
758
+ }
759
+ function setProgress(repo, { phase, percent, detail, state, indeterminate, rootId } = {}) {
760
+ const el = progressItem(repo, rootId);
761
+ if (!el) return;
762
+ if (state) {
763
+ el.classList.remove('ok', 'error');
764
+ if (state === 'ok' || state === 'error') el.classList.add(state);
765
+ }
766
+ if (phase != null) el.querySelector('.phase').textContent = phase;
767
+ if (percent != null) {
768
+ el.querySelector('.pct').textContent = `${percent}%`;
769
+ el.querySelector('.fill').style.width = `${percent}%`;
770
+ el.querySelector('.fill').classList.remove('indeterminate');
771
+ }
772
+ if (indeterminate) {
773
+ el.querySelector('.fill').classList.add('indeterminate');
774
+ el.querySelector('.pct').textContent = '';
775
+ }
776
+ if (detail != null) el.querySelector('.detail').textContent = detail;
777
+ }
778
+ async function streamNewSession(body, { progressRootId = 'newSessionProgress', resultElId = 'newSessionResult' } = {}) {
779
+ const res = await fetch('/api/sessions/new', {
780
+ method: 'POST',
781
+ headers: { 'Content-Type': 'application/json' },
782
+ body: JSON.stringify(body),
783
+ });
784
+ if (!res.ok && res.headers.get('content-type')?.startsWith('application/json')) {
785
+ const j = await res.json();
786
+ throw new Error(j.error || `HTTP ${res.status}`);
787
+ }
788
+ const reader = res.body.getReader();
789
+ const decoder = new TextDecoder();
790
+ let buf = '';
791
+ let final = null;
792
+ while (true) {
793
+ const { done, value } = await reader.read();
794
+ if (done) break;
795
+ buf += decoder.decode(value, { stream: true });
796
+ const lines = buf.split('\n');
797
+ buf = lines.pop();
798
+ for (const line of lines) {
799
+ if (!line.trim()) continue;
800
+ let event;
801
+ try { event = JSON.parse(line); } catch { continue; }
802
+ handleNewSessionEvent(event, { progressRootId, resultElId });
803
+ if (event.type === 'done') final = event;
804
+ }
805
+ }
806
+ if (buf.trim()) {
807
+ try {
808
+ const event = JSON.parse(buf);
809
+ handleNewSessionEvent(event, { progressRootId, resultElId });
810
+ if (event.type === 'done') final = event;
811
+ } catch {}
812
+ }
813
+ return final || { success: false, error: 'stream ended unexpectedly' };
814
+ }
815
+ function handleNewSessionEvent(ev, { progressRootId, resultElId } = {}) {
816
+ const resultEl = document.getElementById(resultElId);
817
+ switch (ev.type) {
818
+ case 'workspace':
819
+ if (resultEl) resultEl.textContent =
820
+ `workspace: ${ev.workspace.path}${ev.created ? ' · newly created' : ''}`;
821
+ break;
822
+ case 'clone-start':
823
+ setProgress(ev.repo, { phase: 'starting', indeterminate: true, rootId: progressRootId });
824
+ break;
825
+ case 'clone-progress':
826
+ setProgress(ev.repo, {
827
+ phase: ev.phase,
828
+ percent: ev.percent,
829
+ detail: ev.detail || (ev.current != null ? `${ev.current}/${ev.total}` : ''),
830
+ rootId: progressRootId,
831
+ });
832
+ break;
833
+ case 'clone-end':
834
+ if (ev.ok) {
835
+ setProgress(ev.repo, { phase: ev.action || 'done', percent: 100, detail: ev.path || '', state: 'ok', rootId: progressRootId });
836
+ } else {
837
+ setProgress(ev.repo, { phase: 'error', detail: ev.error, state: 'error', rootId: progressRootId });
838
+ }
839
+ break;
840
+ case 'launched':
841
+ if (resultEl) resultEl.textContent =
842
+ `terminal launching · pid ${ev.launched.pid} · ${ev.launched.terminal}`;
843
+ break;
844
+ }
282
845
  }
283
846
 
284
- // ---- event wiring ----
847
+ /* ── Modal lifecycle ── */
848
+ function openModal() {
849
+ // refresh modal contents from current state
850
+ renderRepoPicker();
851
+ renderWorkspaces();
852
+ renderModalReposEditor();
853
+ document.getElementById('modalProgress').innerHTML = '';
854
+ document.getElementById('modalResult').textContent = '';
855
+ $('#newSessionModal').hidden = false;
856
+ document.body.style.overflow = 'hidden';
857
+ }
858
+ function closeModal() {
859
+ $('#newSessionModal').hidden = true;
860
+ document.body.style.overflow = '';
861
+ }
862
+
863
+ /* ─────────────────────────────────────────────────────────────
864
+ Custom confirm / prompt — replaces native alert/confirm/prompt
865
+ ───────────────────────────────────────────────────────────── */
866
+
867
+ /* Promise-based confirm. Resolves true on OK, false on cancel / ESC /
868
+ backdrop click. Optional `danger` flag styles the OK button red. */
869
+ function ccsmConfirm(message, { title = 'Confirm', okLabel = 'Confirm', cancelLabel = 'Cancel', danger = false } = {}) {
870
+ return new Promise((resolve) => {
871
+ const backdrop = document.createElement('div');
872
+ backdrop.className = 'modal-backdrop';
873
+ backdrop.setAttribute('role', 'dialog');
874
+ backdrop.setAttribute('aria-modal', 'true');
875
+ backdrop.innerHTML = `
876
+ <div class="modal modal-dialog">
877
+ <header class="modal-head"><h2>${escapeHtml(title)}</h2></header>
878
+ <div class="modal-body"><p class="dialog-msg">${escapeHtml(message)}</p></div>
879
+ <footer class="modal-foot">
880
+ <button class="action" data-action="cancel">${escapeHtml(cancelLabel)}</button>
881
+ <button class="action ${danger ? 'danger' : 'primary'}" data-action="ok">${escapeHtml(okLabel)}</button>
882
+ </footer>
883
+ </div>
884
+ `;
885
+ document.body.appendChild(backdrop);
886
+ const prevOverflow = document.body.style.overflow;
887
+ document.body.style.overflow = 'hidden';
888
+
889
+ const cleanup = (result) => {
890
+ document.removeEventListener('keydown', onKey);
891
+ backdrop.remove();
892
+ document.body.style.overflow = prevOverflow;
893
+ resolve(result);
894
+ };
895
+ const onKey = (ev) => {
896
+ if (ev.key === 'Escape') { ev.preventDefault(); cleanup(false); }
897
+ else if (ev.key === 'Enter') { ev.preventDefault(); cleanup(true); }
898
+ };
899
+ backdrop.addEventListener('click', (ev) => {
900
+ if (ev.target === backdrop) return cleanup(false);
901
+ const btn = ev.target.closest('button[data-action]');
902
+ if (btn) cleanup(btn.dataset.action === 'ok');
903
+ });
904
+ document.addEventListener('keydown', onKey);
905
+ setTimeout(() => backdrop.querySelector('[data-action="ok"]')?.focus(), 50);
906
+ });
907
+ }
908
+
909
+ /* Promise-based prompt. Resolves with entered string (possibly "") on OK,
910
+ null on cancel / ESC / backdrop click. */
911
+ function ccsmPrompt(message, defaultValue = '', { title, okLabel = 'Save', cancelLabel = 'Cancel', placeholder = '' } = {}) {
912
+ return new Promise((resolve) => {
913
+ const backdrop = document.createElement('div');
914
+ backdrop.className = 'modal-backdrop';
915
+ backdrop.setAttribute('role', 'dialog');
916
+ backdrop.setAttribute('aria-modal', 'true');
917
+ backdrop.innerHTML = `
918
+ <div class="modal modal-dialog">
919
+ <header class="modal-head"><h2>${escapeHtml(title || message)}</h2></header>
920
+ <div class="modal-body">
921
+ ${title ? `<p class="dialog-msg">${escapeHtml(message)}</p>` : ''}
922
+ <input type="text" class="input" placeholder="${escapeHtml(placeholder)}" />
923
+ </div>
924
+ <footer class="modal-foot">
925
+ <button class="action" data-action="cancel">${escapeHtml(cancelLabel)}</button>
926
+ <button class="action primary" data-action="ok">${escapeHtml(okLabel)}</button>
927
+ </footer>
928
+ </div>
929
+ `;
930
+ document.body.appendChild(backdrop);
931
+ const prevOverflow = document.body.style.overflow;
932
+ document.body.style.overflow = 'hidden';
933
+
934
+ const input = backdrop.querySelector('input[type="text"]');
935
+ input.value = defaultValue;
936
+
937
+ const cleanup = (result) => {
938
+ document.removeEventListener('keydown', onKey);
939
+ backdrop.remove();
940
+ document.body.style.overflow = prevOverflow;
941
+ resolve(result);
942
+ };
943
+ const onKey = (ev) => {
944
+ if (ev.key === 'Escape') { ev.preventDefault(); cleanup(null); }
945
+ else if (ev.key === 'Enter') { ev.preventDefault(); cleanup(input.value); }
946
+ };
947
+ backdrop.addEventListener('click', (ev) => {
948
+ if (ev.target === backdrop) return cleanup(null);
949
+ const btn = ev.target.closest('button[data-action]');
950
+ if (btn) cleanup(btn.dataset.action === 'ok' ? input.value : null);
951
+ });
952
+ document.addEventListener('keydown', onKey);
953
+ setTimeout(() => { input.focus(); input.select(); }, 50);
954
+ });
955
+ }
956
+
957
+ /* ─────────────────────────────────────────────────────────────
958
+ Wiring
959
+ ───────────────────────────────────────────────────────────── */
285
960
 
286
961
  function wireUp() {
962
+ /* sidebar */
963
+ $$('.nav-item').forEach((b) => {
964
+ b.addEventListener('click', () => selectTab(b.dataset.tab));
965
+ });
966
+ $('#collapseBtn').addEventListener('click', toggleSidebar);
967
+
968
+ /* card fold toggles — click anywhere on the card head folds.
969
+ The chevron just visually indicates state; it's not interactive. */
970
+ $$('.card[data-fold-key] .card-head').forEach((head) => {
971
+ head.addEventListener('click', (ev) => {
972
+ const card = head.closest('.card');
973
+ const key = card?.dataset.foldKey;
974
+ if (key) toggleCardFold(key);
975
+ });
976
+ });
977
+
978
+ /* hash routing */
979
+ const hash = location.hash.slice(1);
980
+ if (TAB_HEADINGS[hash]) state.activeTab = hash;
981
+
287
982
  $('#refreshBtn').onclick = () => refreshAll().then(() => toast('refreshed'));
288
983
 
289
- $('#autoRefresh').onchange = (e) => {
290
- if (e.target.checked) startAutoRefresh();
291
- else stopAutoRefresh();
984
+ /* delegated star + rename across all tables */
985
+ for (const tableSel of ['#sessionsTable', '#recentTable', '#favoritesTable']) {
986
+ $(tableSel).addEventListener('click', (ev) => {
987
+ const starBtn = ev.target.closest('button[data-star]');
988
+ if (starBtn) {
989
+ ev.stopPropagation();
990
+ const sessionId = starBtn.dataset.star;
991
+ const row = starBtn.closest('tr');
992
+ toggleFavorite(sessionId, row);
993
+ return;
994
+ }
995
+ const renameBtn = ev.target.closest('button[data-rename]');
996
+ if (renameBtn) {
997
+ ev.stopPropagation();
998
+ const sessionId = renameBtn.dataset.rename;
999
+ renameSession(sessionId, state.labels[sessionId] || '');
1000
+ return;
1001
+ }
1002
+ });
1003
+ }
1004
+
1005
+ /* favorites table delegated actions (focus / resume / continue) */
1006
+ $('#favoritesTable').addEventListener('click', async (ev) => {
1007
+ const focusBtn = ev.target.closest('button[data-focus]');
1008
+ if (focusBtn) {
1009
+ const sessionId = focusBtn.dataset.focus;
1010
+ focusBtn.disabled = true;
1011
+ try {
1012
+ const r = await api('POST', `/api/sessions/${sessionId}/focus`);
1013
+ if (r.ok && r.activated) toast(`focused · ${r.windowTitle || sessionId.slice(0, 8)}`);
1014
+ else toast(`focus blocked or not running`, 'error');
1015
+ } catch (e) { toast(e.message, 'error'); }
1016
+ finally { focusBtn.disabled = false; }
1017
+ return;
1018
+ }
1019
+ const resumeBtn = ev.target.closest('button[data-resume], button[data-continue]');
1020
+ if (!resumeBtn) return;
1021
+ const sessionId = resumeBtn.dataset.resume || resumeBtn.dataset.continue;
1022
+ const cwd = resumeBtn.dataset.cwd;
1023
+ if (!cwd) return toast('no cwd for this favorite', 'error');
1024
+ resumeBtn.disabled = true;
1025
+ try {
1026
+ await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
1027
+ toast(`opening wt · ${sessionId.slice(0, 8)}…`);
1028
+ } catch (e) { toast(e.message, 'error'); }
1029
+ finally { resumeBtn.disabled = false; }
1030
+ });
1031
+
1032
+ /* inline finder button on Sessions tab */
1033
+ const inlineFinder = $('#finderInlineBtn');
1034
+ if (inlineFinder) {
1035
+ inlineFinder.onclick = async () => {
1036
+ try {
1037
+ await api('POST', '/api/sessions/finder');
1038
+ toast('finder session launching in a new wt window');
1039
+ } catch (e) { toast(e.message, 'error'); }
1040
+ };
1041
+ }
1042
+
1043
+ /* recent pagination (server-side) */
1044
+ $('#recentPrevBtn').onclick = () => {
1045
+ state.recentOffset = Math.max(0, state.recentOffset - state.recentLimit);
1046
+ loadRecent().catch(() => {});
1047
+ };
1048
+ $('#recentNextBtn').onclick = () => {
1049
+ state.recentOffset = state.recentOffset + state.recentLimit;
1050
+ loadRecent().catch(() => {});
1051
+ };
1052
+ $('#recentPageSize').onchange = (e) => {
1053
+ state.recentLimit = Math.max(1, Number(e.target.value) || 10);
1054
+ state.recentOffset = 0;
1055
+ loadRecent().catch(() => {});
1056
+ };
1057
+
1058
+ /* sessions pagination (client-side) */
1059
+ $('#sessPrevBtn').onclick = () => {
1060
+ state.sessionsOffset = Math.max(0, state.sessionsOffset - state.sessionsLimit);
1061
+ renderSessions();
1062
+ };
1063
+ $('#sessNextBtn').onclick = () => {
1064
+ state.sessionsOffset = state.sessionsOffset + state.sessionsLimit;
1065
+ renderSessions();
1066
+ };
1067
+ $('#sessPageSize').onchange = (e) => {
1068
+ state.sessionsLimit = Math.max(1, Number(e.target.value) || 10);
1069
+ state.sessionsOffset = 0;
1070
+ renderSessions();
1071
+ };
1072
+
1073
+ /* favorites pagination (client-side) */
1074
+ $('#favPrevBtn').onclick = () => {
1075
+ state.favoritesOffset = Math.max(0, state.favoritesOffset - state.favoritesLimit);
1076
+ renderFavorites();
1077
+ };
1078
+ $('#favNextBtn').onclick = () => {
1079
+ state.favoritesOffset = state.favoritesOffset + state.favoritesLimit;
1080
+ renderFavorites();
1081
+ };
1082
+ $('#favPageSize').onchange = (e) => {
1083
+ state.favoritesLimit = Math.max(1, Number(e.target.value) || 10);
1084
+ state.favoritesOffset = 0;
1085
+ renderFavorites();
292
1086
  };
293
1087
 
1088
+ /* live sessions actions */
294
1089
  $('#sessionsTable').addEventListener('click', async (ev) => {
1090
+ if (ev.target.closest('button[data-star]') || ev.target.closest('button[data-rename]')) return;
295
1091
  const focusBtn = ev.target.closest('button[data-focus]');
296
1092
  if (focusBtn) {
297
1093
  const sessionId = focusBtn.dataset.focus;
298
1094
  focusBtn.disabled = true;
299
1095
  try {
300
1096
  const r = await api('POST', `/api/sessions/${sessionId}/focus`);
301
- if (r.ok && r.activated) {
302
- toast(`focused: ${r.windowTitle || r.windowProcess || sessionId.slice(0,8)}`);
303
- } else if (r.ok) {
304
- toast(`window found but Windows blocked focus (${r.windowProcess}); try clicking the wt taskbar icon`, 'error');
305
- } else {
306
- toast(`no window for pid — chain: ${(r.chain||[]).map(c=>c.name).join('→')}`, 'error');
307
- }
308
- } catch (e) {
309
- toast(e.message, 'error');
310
- } finally {
311
- focusBtn.disabled = false;
312
- }
1097
+ if (r.ok && r.activated) toast(`focused · ${r.windowTitle || sessionId.slice(0, 8)}`);
1098
+ else if (r.ok) toast(`window found, focus blocked (${r.windowProcess})`, 'error');
1099
+ else toast(`no window for pid · ${(r.chain || []).map((c) => c.name).join('→')}`, 'error');
1100
+ } catch (e) { toast(e.message, 'error'); }
1101
+ finally { focusBtn.disabled = false; }
313
1102
  return;
314
1103
  }
315
- const btn = ev.target.closest('button[data-resume]');
316
- if (!btn) return;
317
- const sessionId = btn.dataset.resume;
318
- const cwd = btn.dataset.cwd;
319
- btn.disabled = true;
1104
+ const resumeBtn = ev.target.closest('button[data-resume]');
1105
+ if (!resumeBtn) return;
1106
+ const sessionId = resumeBtn.dataset.resume;
1107
+ const cwd = resumeBtn.dataset.cwd;
1108
+ resumeBtn.disabled = true;
320
1109
  try {
321
1110
  await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
322
- toast(`opening wt for ${sessionId.slice(0,8)}…`);
323
- } catch (e) {
324
- toast(e.message, 'error');
325
- } finally {
326
- btn.disabled = false;
327
- }
1111
+ toast(`opening wt · ${sessionId.slice(0, 8)}…`);
1112
+ } catch (e) { toast(e.message, 'error'); }
1113
+ finally { resumeBtn.disabled = false; }
328
1114
  });
329
1115
 
1116
+ /* recent continue */
330
1117
  $('#recentTable').addEventListener('click', async (ev) => {
1118
+ if (ev.target.closest('button[data-star]') || ev.target.closest('button[data-rename]')) return;
331
1119
  const btn = ev.target.closest('button[data-continue]');
332
1120
  if (!btn) return;
333
1121
  const sessionId = btn.dataset.continue;
@@ -335,25 +1123,14 @@ function wireUp() {
335
1123
  btn.disabled = true;
336
1124
  try {
337
1125
  await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
338
- toast(`continuing ${sessionId.slice(0, 8)}…`);
1126
+ toast(`continuing · ${sessionId.slice(0, 8)}…`);
339
1127
  setTimeout(() => loadSessions().catch(() => {}), 3000);
340
1128
  setTimeout(() => loadRecent().catch(() => {}), 4000);
341
- } catch (e) {
342
- toast(e.message, 'error');
343
- } finally {
344
- btn.disabled = false;
345
- }
1129
+ } catch (e) { toast(e.message, 'error'); }
1130
+ finally { btn.disabled = false; }
346
1131
  });
347
1132
 
348
- $('#finderBtn').onclick = async () => {
349
- try {
350
- await api('POST', '/api/sessions/finder');
351
- toast('finder session launching in a new wt window');
352
- } catch (e) {
353
- toast(e.message, 'error');
354
- }
355
- };
356
-
1133
+ /* snapshot */
357
1134
  $('#snapshotSaveBtn').onclick = async () => {
358
1135
  try {
359
1136
  const r = await api('POST', '/api/snapshot');
@@ -361,243 +1138,216 @@ function wireUp() {
361
1138
  const h = await api('GET', '/api/snapshot/history');
362
1139
  state.history = h.history;
363
1140
  renderSnapshot();
364
- toast(`saved snapshot with ${r.snapshot.sessions.length} session(s)`);
365
- } catch (e) {
366
- toast(e.message, 'error');
367
- }
1141
+ toast(`saved · ${r.snapshot.sessions.length} session(s)`);
1142
+ } catch (e) { toast(e.message, 'error'); }
368
1143
  };
369
-
370
1144
  $('#snapshotRestoreBtn').onclick = async () => {
371
1145
  const snap = state.snapshot;
372
1146
  if (!snap || !snap.sessions.length) return toast('no sessions in snapshot', 'error');
373
- if (!confirm(`Restore ${snap.sessions.length} session(s)? Each opens a new wt window.`)) return;
1147
+ const ok = await ccsmConfirm(
1148
+ `Restore ${snap.sessions.length} session(s)? Each opens a new wt window.`,
1149
+ { title: 'Restore latest snapshot', okLabel: `Restore ${snap.sessions.length}` }
1150
+ );
1151
+ if (!ok) return;
374
1152
  try {
375
1153
  const r = await api('POST', '/api/snapshot/restore');
376
1154
  toast(`launched ${r.restored.launched.length} / ${r.count}`);
377
- } catch (e) {
378
- toast(e.message, 'error');
379
- }
1155
+ } catch (e) { toast(e.message, 'error'); }
380
1156
  };
381
-
382
1157
  $('#historyRestoreBtn').onclick = async () => {
383
1158
  const file = $('#historySelect').value;
384
1159
  if (!file) return toast('pick a history snapshot first', 'error');
385
- if (!confirm(`Restore from ${file}?`)) return;
1160
+ const ok = await ccsmConfirm(`Restore from ${file}?`, {
1161
+ title: 'Restore from history',
1162
+ okLabel: 'Restore',
1163
+ });
1164
+ if (!ok) return;
386
1165
  try {
387
1166
  const r = await api('POST', '/api/snapshot/restore', { file });
388
1167
  toast(`launched ${r.restored.launched.length} / ${r.count}`);
389
- } catch (e) {
390
- toast(e.message, 'error');
391
- }
1168
+ } catch (e) { toast(e.message, 'error'); }
392
1169
  };
393
1170
 
394
- $('#newSessionBtn').onclick = async () => {
395
- const repos = $$('#repoPicker input:checked').map((i) => i.dataset.repo);
1171
+ /* shared launcher — drives both the inline form (Launch tab) and the FAB modal */
1172
+ async function launchNewSessionFromForm({ chipSel, wsSelId, progressRootId, resultElId, triggerBtn, onSuccess }) {
1173
+ const repos = $$(chipSel).map((i) => i.dataset.repo);
396
1174
  if (repos.length === 0) return toast('select at least one repo', 'error');
397
- const workspace = $('#workspaceSelect').value || undefined;
398
- const btn = $('#newSessionBtn');
399
- btn.disabled = true;
400
- $('#newSessionResult').textContent = '';
401
- resetProgress(repos);
1175
+ const workspace = document.getElementById(wsSelId)?.value || undefined;
1176
+ const resultEl = document.getElementById(resultElId);
1177
+ if (triggerBtn) triggerBtn.disabled = true;
1178
+ if (resultEl) resultEl.textContent = '';
1179
+ resetProgress(repos, progressRootId);
402
1180
  try {
403
- const result = await streamNewSession({ repos, workspace });
1181
+ const result = await streamNewSession({ repos, workspace }, { progressRootId, resultElId });
404
1182
  if (result.success) {
405
1183
  const ws = result.workspace;
406
1184
  const summary = (result.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
407
- $('#newSessionResult').textContent =
408
- `launched in ${ws.path}${result.created ? ' (newly created)' : ''} — ${summary}`;
409
- toast(`launched new session in ${ws.name}`);
1185
+ if (resultEl) resultEl.textContent =
1186
+ `launched in ${ws.path}${result.created ? ' · newly created' : ''} — ${summary}`;
1187
+ toast(`launched · ${ws.name}`);
1188
+ if (onSuccess) onSuccess(result);
410
1189
  } else {
411
- $('#newSessionResult').textContent = `error: ${result.error}`;
1190
+ if (resultEl) resultEl.textContent = `error: ${result.error}`;
412
1191
  toast(result.error || 'new session failed', 'error');
413
1192
  }
414
1193
  await loadWorkspaces();
415
1194
  } catch (e) {
416
- $('#newSessionResult').textContent = `error: ${e.message}`;
1195
+ if (resultEl) resultEl.textContent = `error: ${e.message}`;
417
1196
  toast(e.message, 'error');
418
1197
  } finally {
419
- btn.disabled = false;
1198
+ if (triggerBtn) triggerBtn.disabled = false;
420
1199
  }
1200
+ }
1201
+
1202
+ $('#newSessionBtn').onclick = () => launchNewSessionFromForm({
1203
+ chipSel: '#repoPicker input:checked',
1204
+ wsSelId: 'workspaceSelect',
1205
+ progressRootId: 'newSessionProgress',
1206
+ resultElId: 'newSessionResult',
1207
+ triggerBtn: $('#newSessionBtn'),
1208
+ });
1209
+
1210
+ /* FAB → modal */
1211
+ $('#newSessionFab').onclick = () => openModal();
1212
+ $('#modalCloseBtn').onclick = () => closeModal();
1213
+ $('#modalCancelBtn').onclick = () => closeModal();
1214
+ $('#newSessionModal').addEventListener('click', (ev) => {
1215
+ if (ev.target === $('#newSessionModal')) closeModal();
1216
+ });
1217
+ document.addEventListener('keydown', (ev) => {
1218
+ if (ev.key === 'Escape' && !$('#newSessionModal').hidden) closeModal();
1219
+ });
1220
+
1221
+ $('#modalLaunchBtn').onclick = () => launchNewSessionFromForm({
1222
+ chipSel: '#modalRepoPicker input:checked',
1223
+ wsSelId: 'modalWorkspaceSelect',
1224
+ progressRootId: 'modalProgress',
1225
+ resultElId: 'modalResult',
1226
+ triggerBtn: $('#modalLaunchBtn'),
1227
+ onSuccess: () => setTimeout(() => closeModal(), 1500),
1228
+ });
1229
+
1230
+ /* modal inline repos editor */
1231
+ $('#modalAddRepoBtn').onclick = () => {
1232
+ state.config.repos = state.config.repos || [];
1233
+ state.config.repos.push({ name: '', url: '', defaultSelected: false });
1234
+ renderModalReposEditor();
1235
+ };
1236
+ $('#modalReposTable').addEventListener('click', (ev) => {
1237
+ const rm = ev.target.closest('button[data-modal-remove]');
1238
+ if (!rm) return;
1239
+ const idx = Number(rm.dataset.modalRemove);
1240
+ state.config.repos.splice(idx, 1);
1241
+ renderModalReposEditor();
1242
+ });
1243
+ $('#modalSaveReposBtn').onclick = async () => {
1244
+ const repos = $$('#modalReposTable tbody tr').map((tr) => {
1245
+ const inputs = tr.querySelectorAll('input');
1246
+ return {
1247
+ name: inputs[0].value.trim(),
1248
+ url: inputs[1].value.trim(),
1249
+ defaultSelected: inputs[2].checked,
1250
+ };
1251
+ }).filter((r) => r.name && r.url);
1252
+ try {
1253
+ const cfg = await api('PUT', '/api/config', { ...state.config, repos });
1254
+ state.config = cfg;
1255
+ renderConfig(); // sync Configure tab
1256
+ renderRepoPicker(); // sync both chip pickers
1257
+ renderModalReposEditor(); // refresh modal editor
1258
+ $('#modalReposSavedAt').textContent = `saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`;
1259
+ toast('repos saved');
1260
+ } catch (e) { toast(e.message, 'error'); }
421
1261
  };
422
1262
 
423
- $('#saveConfigBtn').onclick = async () => {
1263
+ /* config save */
1264
+ const saveConfig = async () => {
424
1265
  const next = readConfigFromForm();
425
1266
  try {
426
1267
  const cfg = await api('PUT', '/api/config', next);
427
1268
  state.config = cfg;
428
1269
  renderConfig();
429
1270
  renderRepoPicker();
430
- $('#configSavedAt').textContent = `saved at ${new Date().toLocaleTimeString()}`;
1271
+ renderHeaderStatus();
1272
+ $('#configSavedAt').textContent = `saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`;
1273
+ setConfigDirty(false);
431
1274
  toast('config saved');
432
1275
  await loadWorkspaces();
433
- } catch (e) {
434
- toast(e.message, 'error');
435
- }
1276
+ } catch (e) { toast(e.message, 'error'); }
1277
+ };
1278
+ $('#saveConfigBtn').onclick = saveConfig;
1279
+ $('#dirtyBannerSaveBtn').onclick = saveConfig;
1280
+ $('#dirtyBannerDiscardBtn').onclick = async () => {
1281
+ const ok = await ccsmConfirm('Discard your unsaved changes?', {
1282
+ title: 'Discard changes',
1283
+ okLabel: 'Discard',
1284
+ danger: true,
1285
+ });
1286
+ if (!ok) return;
1287
+ // re-fetch config from server and re-render
1288
+ state.config = await api('GET', '/api/config');
1289
+ renderConfig();
1290
+ renderRepoPicker();
1291
+ renderHeaderStatus();
1292
+ setConfigDirty(false);
1293
+ toast('changes discarded');
436
1294
  };
437
-
438
1295
  $('#addRepoBtn').onclick = () => {
439
1296
  state.config.repos.push({ name: '', url: '', defaultSelected: false });
440
1297
  renderConfig();
1298
+ setConfigDirty(true);
441
1299
  };
442
-
443
1300
  $('#reposTable').addEventListener('click', (ev) => {
444
1301
  const rm = ev.target.closest('button[data-remove-repo]');
445
1302
  if (!rm) return;
446
1303
  const idx = Number(rm.dataset.removeRepo);
447
1304
  state.config.repos.splice(idx, 1);
448
1305
  renderConfig();
1306
+ setConfigDirty(true);
449
1307
  });
450
- }
451
1308
 
452
- // ---- auto refresh ----
1309
+ /* Mark dirty on any user-initiated change in the Configure tab */
1310
+ const configPanel = document.querySelector('.tab-panel[data-panel="configure"]');
1311
+ if (configPanel) {
1312
+ const onChange = () => setConfigDirty(true);
1313
+ configPanel.addEventListener('input', onChange);
1314
+ configPanel.addEventListener('change', onChange);
1315
+ }
1316
+ }
453
1317
 
454
1318
  function startAutoRefresh() {
455
- stopAutoRefresh();
1319
+ if (state.autoTimer) clearInterval(state.autoTimer);
456
1320
  state.autoTimer = setInterval(() => {
457
1321
  loadSessions().catch(() => {});
458
1322
  loadRecent().catch(() => {});
459
1323
  loadSnapshot().catch(() => {});
1324
+ pollHealth();
460
1325
  }, 5000);
461
1326
  }
462
- function stopAutoRefresh() {
463
- if (state.autoTimer) { clearInterval(state.autoTimer); state.autoTimer = null; }
464
- }
465
-
466
- // ---- NDJSON streaming for /api/sessions/new ----
467
-
468
- function resetProgress(repoNames) {
469
- const root = $('#newSessionProgress');
470
- root.innerHTML = '';
471
- for (const r of repoNames) {
472
- const el = document.createElement('div');
473
- el.className = 'progress-item';
474
- el.dataset.repo = r;
475
- el.innerHTML = `
476
- <div class="head">
477
- <span class="name">${escapeHtml(r)}</span>
478
- <span class="phase">queued</span>
479
- <span class="pct"></span>
480
- </div>
481
- <div class="progress-bar"><div class="fill"></div></div>
482
- <div class="detail"></div>
483
- `;
484
- root.appendChild(el);
485
- }
486
- }
487
-
488
- function progressItem(repo) {
489
- return document.querySelector(`#newSessionProgress .progress-item[data-repo="${CSS.escape(repo)}"]`);
490
- }
491
-
492
- function setProgress(repo, { phase, percent, detail, state, indeterminate } = {}) {
493
- const el = progressItem(repo);
494
- if (!el) return;
495
- if (state) {
496
- el.classList.remove('ok', 'error');
497
- if (state === 'ok' || state === 'error') el.classList.add(state);
498
- }
499
- if (phase != null) el.querySelector('.phase').textContent = phase;
500
- if (percent != null) {
501
- el.querySelector('.pct').textContent = `${percent}%`;
502
- el.querySelector('.fill').style.width = `${percent}%`;
503
- el.querySelector('.fill').classList.remove('indeterminate');
504
- }
505
- if (indeterminate) {
506
- el.querySelector('.fill').classList.add('indeterminate');
507
- el.querySelector('.pct').textContent = '';
508
- }
509
- if (detail != null) el.querySelector('.detail').textContent = detail;
510
- }
511
-
512
- async function streamNewSession(body) {
513
- const res = await fetch('/api/sessions/new', {
514
- method: 'POST',
515
- headers: { 'Content-Type': 'application/json' },
516
- body: JSON.stringify(body),
517
- });
518
- if (!res.ok && res.headers.get('content-type')?.startsWith('application/json')) {
519
- const j = await res.json();
520
- throw new Error(j.error || `HTTP ${res.status}`);
521
- }
522
- const reader = res.body.getReader();
523
- const decoder = new TextDecoder();
524
- let buf = '';
525
- let final = null;
526
- while (true) {
527
- const { done, value } = await reader.read();
528
- if (done) break;
529
- buf += decoder.decode(value, { stream: true });
530
- const lines = buf.split('\n');
531
- buf = lines.pop();
532
- for (const line of lines) {
533
- if (!line.trim()) continue;
534
- let event;
535
- try { event = JSON.parse(line); } catch { continue; }
536
- handleNewSessionEvent(event);
537
- if (event.type === 'done') final = event;
538
- }
539
- }
540
- if (buf.trim()) {
541
- try {
542
- const event = JSON.parse(buf);
543
- handleNewSessionEvent(event);
544
- if (event.type === 'done') final = event;
545
- } catch {}
546
- }
547
- return final || { success: false, error: 'stream ended unexpectedly' };
548
- }
549
1327
 
550
- function handleNewSessionEvent(ev) {
551
- switch (ev.type) {
552
- case 'workspace':
553
- $('#newSessionResult').textContent =
554
- `workspace: ${ev.workspace.path}${ev.created ? ' (new)' : ''}`;
555
- break;
556
- case 'clone-start':
557
- setProgress(ev.repo, { phase: 'starting', indeterminate: true });
558
- break;
559
- case 'clone-progress':
560
- setProgress(ev.repo, {
561
- phase: ev.phase,
562
- percent: ev.percent,
563
- detail: ev.detail || (ev.current != null ? `${ev.current}/${ev.total}` : ''),
564
- });
565
- break;
566
- case 'clone-end':
567
- if (ev.ok) {
568
- setProgress(ev.repo, {
569
- phase: ev.action || 'done',
570
- percent: 100,
571
- detail: ev.path || '',
572
- state: 'ok',
573
- });
574
- } else {
575
- setProgress(ev.repo, {
576
- phase: 'error',
577
- detail: ev.error,
578
- state: 'error',
579
- });
580
- }
581
- break;
582
- case 'launched':
583
- $('#newSessionResult').textContent =
584
- `terminal launching — pid ${ev.launched.pid} (${ev.launched.terminal})`;
585
- break;
586
- case 'done':
587
- // handled by caller
588
- break;
589
- }
1328
+ // Re-render favorites when sessions update so live status of favorited rows refreshes.
1329
+ function reRenderFavoritesIfNeeded() {
1330
+ if (Object.keys(state.favorites).length === 0) return;
1331
+ renderFavorites();
590
1332
  }
591
1333
 
592
- // ---- boot ----
1334
+ /* ─────────────────────────────────────────────────────────────
1335
+ Boot
1336
+ ───────────────────────────────────────────────────────────── */
593
1337
 
594
1338
  (async () => {
1339
+ restoreSidebar();
1340
+ restoreCardFolds();
595
1341
  wireUp();
596
1342
  try {
597
1343
  await loadConfig();
598
1344
  await refreshAll();
1345
+ selectTab(state.activeTab);
599
1346
  startAutoRefresh();
1347
+ pollHealth();
1348
+ tickClock();
1349
+ state.clockTimer = setInterval(tickClock, 1000);
600
1350
  } catch (e) {
601
- toast('initial load failed: ' + e.message, 'error');
1351
+ toast('initial load failed · ' + e.message, 'error');
602
1352
  }
603
1353
  })();