@bakapiano/ccsm 0.5.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
@@ -14,8 +14,16 @@ const state = {
14
14
  recent: [],
15
15
  recentTotal: 0,
16
16
  recentOffset: 0,
17
- recentLimit: 15,
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' },
18
25
  favorites: {}, // { sessionId: { sessionId, cwd, title, gitBranch, addedAt, label } }
26
+ labels: {}, // { sessionId: customLabel } — user-defined title overrides
19
27
  workspaces: [],
20
28
  snapshot: null,
21
29
  history: [],
@@ -77,6 +85,25 @@ function escapeHtml(s) {
77
85
 
78
86
  /* Mark a table as already-rendered so animations don't replay on
79
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
+
80
107
  function markRendered(tableId) {
81
108
  const tb = document.querySelector(`#${tableId} tbody`);
82
109
  if (!tb) return;
@@ -105,6 +132,21 @@ function starButtonHtml(sessionId, isFav) {
105
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>`;
106
133
  }
107
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
+
108
150
  /* ─────────────────────────────────────────────────────────────
109
151
  Sidebar — tabs + collapse
110
152
  ───────────────────────────────────────────────────────────── */
@@ -136,6 +178,38 @@ function restoreSidebar() {
136
178
  if (v === 'true') $('#sidebar').setAttribute('data-collapsed', 'true');
137
179
  }
138
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
+
139
213
  /* ─────────────────────────────────────────────────────────────
140
214
  Render: sessions (live)
141
215
  ───────────────────────────────────────────────────────────── */
@@ -143,15 +217,24 @@ function restoreSidebar() {
143
217
  function renderSessions() {
144
218
  const tb = $('#sessionsTable tbody');
145
219
  tb.innerHTML = '';
146
- 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) {
147
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;
148
230
  const tr = document.createElement('tr');
149
231
  tr.innerHTML = `
150
232
  <td><span class="status-mark ${escapeHtml(s.status)}" title="${escapeHtml(s.status)}"></span></td>
151
233
  <td>
152
234
  <div class="title-cell">
153
235
  <div class="title-row">
154
- <span class="primary" title="${escapeHtml(s.title || '')}">${escapeHtml(s.title || '(no title)')}</span>
236
+ <span class="primary" title="${escapeHtml(tooltip)}">${escapeHtml(shown)}</span>
237
+ ${renameButtonHtml(s.sessionId, hasLabel)}
155
238
  ${starButtonHtml(s.sessionId, isFav)}
156
239
  </div>
157
240
  <div class="secondary" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0, 8))}${s.version ? ' · ' + escapeHtml(s.version) : ''}</div>
@@ -178,6 +261,11 @@ function renderSessions() {
178
261
  ? `${state.sessions.length} live · refreshed ${ts}`
179
262
  : 'no live sessions';
180
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
+ });
181
269
  markRendered('sessionsTable');
182
270
  }
183
271
 
@@ -191,12 +279,16 @@ function renderRecent() {
191
279
  const recent = state.recent || [];
192
280
  for (const s of recent) {
193
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;
194
285
  const tr = document.createElement('tr');
195
286
  tr.innerHTML = `
196
287
  <td>
197
288
  <div class="title-cell">
198
289
  <div class="title-row">
199
- <span class="primary" title="${escapeHtml(s.title || '')}">${escapeHtml(s.title || '(no title)')}</span>
290
+ <span class="primary" title="${escapeHtml(tooltip)}">${escapeHtml(shown)}</span>
291
+ ${renameButtonHtml(s.sessionId, hasLabel)}
200
292
  ${starButtonHtml(s.sessionId, isFav)}
201
293
  </div>
202
294
  <div class="secondary" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0, 8))}</div>
@@ -218,25 +310,14 @@ function renderRecent() {
218
310
  tb.appendChild(tr);
219
311
  }
220
312
  $('#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`
313
+ $('#recentMeta').textContent = state.recentTotal
314
+ ? `${state.recentTotal} total · sorted by jsonl mtime, excluding live`
229
315
  : '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
- }
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
+ });
240
321
  markRendered('recentTable');
241
322
  }
242
323
 
@@ -246,7 +327,11 @@ function renderRecent() {
246
327
  function renderFavorites() {
247
328
  const tb = $('#favoritesTable tbody');
248
329
  tb.innerHTML = '';
249
- const list = Object.values(state.favorites).sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
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);
250
335
  for (const f of list) {
251
336
  const liveMatch = state.sessions.find((s) => s.sessionId === f.sessionId);
252
337
  const title = liveMatch?.title || f.title;
@@ -256,12 +341,16 @@ function renderFavorites() {
256
341
  ? `<button class="action small primary" data-focus="${escapeHtml(f.sessionId)}" title="raise the wt window">Focus</button>
257
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>`
258
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;
259
347
  const tr = document.createElement('tr');
260
348
  tr.innerHTML = `
261
349
  <td>
262
350
  <div class="title-cell">
263
351
  <div class="title-row">
264
- <span class="primary" title="${escapeHtml(title || '')}">${escapeHtml(title || '(no title)')}</span>
352
+ <span class="primary" title="${escapeHtml(tooltip)}">${escapeHtml(shown)}</span>
353
+ ${renameButtonHtml(f.sessionId, hasLabel)}
265
354
  ${starButtonHtml(f.sessionId, true)}
266
355
  </div>
267
356
  <div class="secondary" title="${escapeHtml(f.sessionId)}">
@@ -278,12 +367,17 @@ function renderFavorites() {
278
367
  tr.dataset.title = title || '';
279
368
  tb.appendChild(tr);
280
369
  }
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`
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`
286
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
+ });
287
381
  markRendered('favoritesTable');
288
382
  }
289
383
 
@@ -339,11 +433,14 @@ function renderWorkspaces() {
339
433
  grid.appendChild(card);
340
434
  }
341
435
 
342
- const sel = $('#workspaceSelect');
343
- sel.innerHTML = '<option value="">auto — find or create unused</option>' +
436
+ const opts = '<option value="">auto — find or create unused</option>' +
344
437
  state.workspaces.filter((w) => !w.inUse).map((w) =>
345
438
  `<option value="${escapeHtml(w.name)}">${escapeHtml(w.name)}</option>`
346
439
  ).join('');
440
+ for (const id of ['workspaceSelect', 'modalWorkspaceSelect']) {
441
+ const el = document.getElementById(id);
442
+ if (el) el.innerHTML = opts;
443
+ }
347
444
 
348
445
  if (state.config) $('#workDirDisplay').textContent = state.config.workDir;
349
446
  }
@@ -353,24 +450,45 @@ function renderWorkspaces() {
353
450
  ───────────────────────────────────────────────────────────── */
354
451
 
355
452
  function renderRepoPicker() {
356
- const root = $('#repoPicker');
357
453
  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);
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
+ }
371
471
  }
372
472
  }
373
473
 
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
+
374
492
  /* ─────────────────────────────────────────────────────────────
375
493
  Render: config form
376
494
  ───────────────────────────────────────────────────────────── */
@@ -384,6 +502,7 @@ function renderConfig() {
384
502
  $('#cfgClaudeCommand').value = state.config.claudeCommand || 'claude';
385
503
  $('#cfgCommandShell').value = state.config.commandShell || 'pwsh';
386
504
  $('#cfgAutoFocus').checked = state.config.autoFocusOnLaunch !== false;
505
+ $('#cfgFocusCenter').checked = state.config.focusMovesToCenter === true;
387
506
  $('#cfgBrowserMode').value =
388
507
  state.config.browserMode ||
389
508
  (state.config.autoOpenBrowser === false ? 'none' : 'app');
@@ -428,6 +547,7 @@ function readConfigFromForm() {
428
547
  terminal: $('#cfgTerminal').value || 'wt',
429
548
  commandShell: $('#cfgCommandShell').value || 'pwsh',
430
549
  autoFocusOnLaunch: $('#cfgAutoFocus').checked,
550
+ focusMovesToCenter: $('#cfgFocusCenter').checked,
431
551
  browserMode: $('#cfgBrowserMode').value || 'app',
432
552
  finderPrompt: $('#cfgFinderPrompt').value,
433
553
  repos,
@@ -453,6 +573,42 @@ function tickClock() {
453
573
  if (el) el.textContent = t;
454
574
  }
455
575
 
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
+ }
610
+ }
611
+
456
612
  /* ─────────────────────────────────────────────────────────────
457
613
  Loaders
458
614
  ───────────────────────────────────────────────────────────── */
@@ -492,6 +648,47 @@ async function loadFavorites() {
492
648
  } catch (e) { /* ignore */ }
493
649
  }
494
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
+
495
692
  async function toggleFavorite(sessionId, sourceRow) {
496
693
  const wasFav = !!state.favorites[sessionId];
497
694
  if (wasFav) {
@@ -529,15 +726,16 @@ async function loadWorkspaces() {
529
726
  renderWorkspaces();
530
727
  }
531
728
  async function refreshAll() {
532
- await Promise.all([loadSessions(), loadRecent(), loadSnapshot(), loadWorkspaces(), loadFavorites()]);
729
+ await Promise.all([loadSessions(), loadRecent(), loadSnapshot(), loadWorkspaces(), loadFavorites(), loadLabels()]);
533
730
  }
534
731
 
535
732
  /* ─────────────────────────────────────────────────────────────
536
733
  Clone progress stream (NDJSON)
537
734
  ───────────────────────────────────────────────────────────── */
538
735
 
539
- function resetProgress(repoNames) {
540
- const root = $('#newSessionProgress');
736
+ function resetProgress(repoNames, rootId = 'newSessionProgress') {
737
+ const root = document.getElementById(rootId);
738
+ if (!root) return;
541
739
  root.innerHTML = '';
542
740
  for (const r of repoNames) {
543
741
  const el = document.createElement('div');
@@ -555,11 +753,11 @@ function resetProgress(repoNames) {
555
753
  root.appendChild(el);
556
754
  }
557
755
  }
558
- function progressItem(repo) {
559
- return document.querySelector(`#newSessionProgress .progress-item[data-repo="${CSS.escape(repo)}"]`);
756
+ function progressItem(repo, rootId = 'newSessionProgress') {
757
+ return document.querySelector(`#${rootId} .progress-item[data-repo="${CSS.escape(repo)}"]`);
560
758
  }
561
- function setProgress(repo, { phase, percent, detail, state, indeterminate } = {}) {
562
- const el = progressItem(repo);
759
+ function setProgress(repo, { phase, percent, detail, state, indeterminate, rootId } = {}) {
760
+ const el = progressItem(repo, rootId);
563
761
  if (!el) return;
564
762
  if (state) {
565
763
  el.classList.remove('ok', 'error');
@@ -577,7 +775,7 @@ function setProgress(repo, { phase, percent, detail, state, indeterminate } = {}
577
775
  }
578
776
  if (detail != null) el.querySelector('.detail').textContent = detail;
579
777
  }
580
- async function streamNewSession(body) {
778
+ async function streamNewSession(body, { progressRootId = 'newSessionProgress', resultElId = 'newSessionResult' } = {}) {
581
779
  const res = await fetch('/api/sessions/new', {
582
780
  method: 'POST',
583
781
  headers: { 'Content-Type': 'application/json' },
@@ -601,49 +799,161 @@ async function streamNewSession(body) {
601
799
  if (!line.trim()) continue;
602
800
  let event;
603
801
  try { event = JSON.parse(line); } catch { continue; }
604
- handleNewSessionEvent(event);
802
+ handleNewSessionEvent(event, { progressRootId, resultElId });
605
803
  if (event.type === 'done') final = event;
606
804
  }
607
805
  }
608
806
  if (buf.trim()) {
609
807
  try {
610
808
  const event = JSON.parse(buf);
611
- handleNewSessionEvent(event);
809
+ handleNewSessionEvent(event, { progressRootId, resultElId });
612
810
  if (event.type === 'done') final = event;
613
811
  } catch {}
614
812
  }
615
813
  return final || { success: false, error: 'stream ended unexpectedly' };
616
814
  }
617
- function handleNewSessionEvent(ev) {
815
+ function handleNewSessionEvent(ev, { progressRootId, resultElId } = {}) {
816
+ const resultEl = document.getElementById(resultElId);
618
817
  switch (ev.type) {
619
818
  case 'workspace':
620
- $('#newSessionResult').textContent =
819
+ if (resultEl) resultEl.textContent =
621
820
  `workspace: ${ev.workspace.path}${ev.created ? ' · newly created' : ''}`;
622
821
  break;
623
822
  case 'clone-start':
624
- setProgress(ev.repo, { phase: 'starting', indeterminate: true });
823
+ setProgress(ev.repo, { phase: 'starting', indeterminate: true, rootId: progressRootId });
625
824
  break;
626
825
  case 'clone-progress':
627
826
  setProgress(ev.repo, {
628
827
  phase: ev.phase,
629
828
  percent: ev.percent,
630
829
  detail: ev.detail || (ev.current != null ? `${ev.current}/${ev.total}` : ''),
830
+ rootId: progressRootId,
631
831
  });
632
832
  break;
633
833
  case 'clone-end':
634
834
  if (ev.ok) {
635
- setProgress(ev.repo, { phase: ev.action || 'done', percent: 100, detail: ev.path || '', state: 'ok' });
835
+ setProgress(ev.repo, { phase: ev.action || 'done', percent: 100, detail: ev.path || '', state: 'ok', rootId: progressRootId });
636
836
  } else {
637
- setProgress(ev.repo, { phase: 'error', detail: ev.error, state: 'error' });
837
+ setProgress(ev.repo, { phase: 'error', detail: ev.error, state: 'error', rootId: progressRootId });
638
838
  }
639
839
  break;
640
840
  case 'launched':
641
- $('#newSessionResult').textContent =
841
+ if (resultEl) resultEl.textContent =
642
842
  `terminal launching · pid ${ev.launched.pid} · ${ev.launched.terminal}`;
643
843
  break;
644
844
  }
645
845
  }
646
846
 
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
+
647
957
  /* ─────────────────────────────────────────────────────────────
648
958
  Wiring
649
959
  ───────────────────────────────────────────────────────────── */
@@ -655,21 +965,40 @@ function wireUp() {
655
965
  });
656
966
  $('#collapseBtn').addEventListener('click', toggleSidebar);
657
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
+
658
978
  /* hash routing */
659
979
  const hash = location.hash.slice(1);
660
980
  if (TAB_HEADINGS[hash]) state.activeTab = hash;
661
981
 
662
982
  $('#refreshBtn').onclick = () => refreshAll().then(() => toast('refreshed'));
663
983
 
664
- /* delegated star toggle across all tables */
984
+ /* delegated star + rename across all tables */
665
985
  for (const tableSel of ['#sessionsTable', '#recentTable', '#favoritesTable']) {
666
986
  $(tableSel).addEventListener('click', (ev) => {
667
987
  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);
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
+ }
673
1002
  });
674
1003
  }
675
1004
 
@@ -700,13 +1029,18 @@ function wireUp() {
700
1029
  finally { resumeBtn.disabled = false; }
701
1030
  });
702
1031
 
703
- /* inline finder button on Sessions tab — same handler as the sidebar one */
1032
+ /* inline finder button on Sessions tab */
704
1033
  const inlineFinder = $('#finderInlineBtn');
705
1034
  if (inlineFinder) {
706
- inlineFinder.onclick = () => $('#finderBtn').click();
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
+ };
707
1041
  }
708
1042
 
709
- /* recent pagination */
1043
+ /* recent pagination (server-side) */
710
1044
  $('#recentPrevBtn').onclick = () => {
711
1045
  state.recentOffset = Math.max(0, state.recentOffset - state.recentLimit);
712
1046
  loadRecent().catch(() => {});
@@ -716,14 +1050,44 @@ function wireUp() {
716
1050
  loadRecent().catch(() => {});
717
1051
  };
718
1052
  $('#recentPageSize').onchange = (e) => {
719
- state.recentLimit = Math.max(1, Number(e.target.value) || 15);
1053
+ state.recentLimit = Math.max(1, Number(e.target.value) || 10);
720
1054
  state.recentOffset = 0;
721
1055
  loadRecent().catch(() => {});
722
1056
  };
723
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();
1086
+ };
1087
+
724
1088
  /* live sessions actions */
725
1089
  $('#sessionsTable').addEventListener('click', async (ev) => {
726
- if (ev.target.closest('button[data-star]')) return;
1090
+ if (ev.target.closest('button[data-star]') || ev.target.closest('button[data-rename]')) return;
727
1091
  const focusBtn = ev.target.closest('button[data-focus]');
728
1092
  if (focusBtn) {
729
1093
  const sessionId = focusBtn.dataset.focus;
@@ -751,7 +1115,7 @@ function wireUp() {
751
1115
 
752
1116
  /* recent continue */
753
1117
  $('#recentTable').addEventListener('click', async (ev) => {
754
- if (ev.target.closest('button[data-star]')) return;
1118
+ if (ev.target.closest('button[data-star]') || ev.target.closest('button[data-rename]')) return;
755
1119
  const btn = ev.target.closest('button[data-continue]');
756
1120
  if (!btn) return;
757
1121
  const sessionId = btn.dataset.continue;
@@ -766,14 +1130,6 @@ function wireUp() {
766
1130
  finally { btn.disabled = false; }
767
1131
  });
768
1132
 
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
1133
  /* snapshot */
778
1134
  $('#snapshotSaveBtn').onclick = async () => {
779
1135
  try {
@@ -788,7 +1144,11 @@ function wireUp() {
788
1144
  $('#snapshotRestoreBtn').onclick = async () => {
789
1145
  const snap = state.snapshot;
790
1146
  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;
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;
792
1152
  try {
793
1153
  const r = await api('POST', '/api/snapshot/restore');
794
1154
  toast(`launched ${r.restored.launched.length} / ${r.count}`);
@@ -797,43 +1157,111 @@ function wireUp() {
797
1157
  $('#historyRestoreBtn').onclick = async () => {
798
1158
  const file = $('#historySelect').value;
799
1159
  if (!file) return toast('pick a history snapshot first', 'error');
800
- 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;
801
1165
  try {
802
1166
  const r = await api('POST', '/api/snapshot/restore', { file });
803
1167
  toast(`launched ${r.restored.launched.length} / ${r.count}`);
804
1168
  } catch (e) { toast(e.message, 'error'); }
805
1169
  };
806
1170
 
807
- /* new session */
808
- $('#newSessionBtn').onclick = async () => {
809
- 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);
810
1174
  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);
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);
816
1180
  try {
817
- const result = await streamNewSession({ repos, workspace });
1181
+ const result = await streamNewSession({ repos, workspace }, { progressRootId, resultElId });
818
1182
  if (result.success) {
819
1183
  const ws = result.workspace;
820
1184
  const summary = (result.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
821
- $('#newSessionResult').textContent =
1185
+ if (resultEl) resultEl.textContent =
822
1186
  `launched in ${ws.path}${result.created ? ' · newly created' : ''} — ${summary}`;
823
1187
  toast(`launched · ${ws.name}`);
1188
+ if (onSuccess) onSuccess(result);
824
1189
  } else {
825
- $('#newSessionResult').textContent = `error: ${result.error}`;
1190
+ if (resultEl) resultEl.textContent = `error: ${result.error}`;
826
1191
  toast(result.error || 'new session failed', 'error');
827
1192
  }
828
1193
  await loadWorkspaces();
829
1194
  } catch (e) {
830
- $('#newSessionResult').textContent = `error: ${e.message}`;
1195
+ if (resultEl) resultEl.textContent = `error: ${e.message}`;
831
1196
  toast(e.message, 'error');
832
- } finally { btn.disabled = false; }
1197
+ } finally {
1198
+ if (triggerBtn) triggerBtn.disabled = false;
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'); }
833
1261
  };
834
1262
 
835
1263
  /* config save */
836
- $('#saveConfigBtn').onclick = async () => {
1264
+ const saveConfig = async () => {
837
1265
  const next = readConfigFromForm();
838
1266
  try {
839
1267
  const cfg = await api('PUT', '/api/config', next);
@@ -842,13 +1270,32 @@ function wireUp() {
842
1270
  renderRepoPicker();
843
1271
  renderHeaderStatus();
844
1272
  $('#configSavedAt').textContent = `saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`;
1273
+ setConfigDirty(false);
845
1274
  toast('config saved');
846
1275
  await loadWorkspaces();
847
1276
  } catch (e) { toast(e.message, 'error'); }
848
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');
1294
+ };
849
1295
  $('#addRepoBtn').onclick = () => {
850
1296
  state.config.repos.push({ name: '', url: '', defaultSelected: false });
851
1297
  renderConfig();
1298
+ setConfigDirty(true);
852
1299
  };
853
1300
  $('#reposTable').addEventListener('click', (ev) => {
854
1301
  const rm = ev.target.closest('button[data-remove-repo]');
@@ -856,7 +1303,16 @@ function wireUp() {
856
1303
  const idx = Number(rm.dataset.removeRepo);
857
1304
  state.config.repos.splice(idx, 1);
858
1305
  renderConfig();
1306
+ setConfigDirty(true);
859
1307
  });
1308
+
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
+ }
860
1316
  }
861
1317
 
862
1318
  function startAutoRefresh() {
@@ -865,6 +1321,7 @@ function startAutoRefresh() {
865
1321
  loadSessions().catch(() => {});
866
1322
  loadRecent().catch(() => {});
867
1323
  loadSnapshot().catch(() => {});
1324
+ pollHealth();
868
1325
  }, 5000);
869
1326
  }
870
1327
 
@@ -880,12 +1337,14 @@ function reRenderFavoritesIfNeeded() {
880
1337
 
881
1338
  (async () => {
882
1339
  restoreSidebar();
1340
+ restoreCardFolds();
883
1341
  wireUp();
884
1342
  try {
885
1343
  await loadConfig();
886
1344
  await refreshAll();
887
1345
  selectTab(state.activeTab);
888
1346
  startAutoRefresh();
1347
+ pollHealth();
889
1348
  tickClock();
890
1349
  state.clockTimer = setInterval(tickClock, 1000);
891
1350
  } catch (e) {