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