@bakapiano/ccsm 0.3.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,128 +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
 
86
- // ---- snapshot render ----
184
+ /* ─────────────────────────────────────────────────────────────
185
+ Render: recently closed
186
+ ───────────────────────────────────────────────────────────── */
187
+
188
+ function renderRecent() {
189
+ const tb = $('#recentTable tbody');
190
+ tb.innerHTML = '';
191
+ const recent = state.recent || [];
192
+ for (const s of recent) {
193
+ const isFav = !!state.favorites[s.sessionId];
194
+ const tr = document.createElement('tr');
195
+ tr.innerHTML = `
196
+ <td>
197
+ <div class="title-cell">
198
+ <div class="title-row">
199
+ <span class="primary" title="${escapeHtml(s.title || '')}">${escapeHtml(s.title || '(no title)')}</span>
200
+ ${starButtonHtml(s.sessionId, isFav)}
201
+ </div>
202
+ <div class="secondary" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0, 8))}</div>
203
+ </div>
204
+ </td>
205
+ <td><div class="path-cell" title="${escapeHtml(s.cwd || '')}">${escapeHtml(s.cwd || '')}</div></td>
206
+ <td>${s.gitBranch ? `<span class="branch-tag">${escapeHtml(s.gitBranch)}</span>` : '<span class="muted-text">—</span>'}</td>
207
+ <td class="num" title="${escapeHtml(fmtTime(s.updatedAt))}">${escapeHtml(fmtAgo(s.updatedAt))}</td>
208
+ <td class="num" title="${escapeHtml(fmtTime(s.startedAt))}">${escapeHtml(fmtAgo(s.startedAt))}</td>
209
+ <td>
210
+ <div class="row-actions">
211
+ <button class="action small primary" data-continue="${escapeHtml(s.sessionId)}" data-cwd="${escapeHtml(s.cwd)}" title="claude --resume in a fresh wt window">Continue ↗</button>
212
+ </div>
213
+ </td>
214
+ `;
215
+ tr.dataset.cwd = s.cwd || '';
216
+ tr.dataset.title = s.title || '';
217
+ tr.dataset.gitBranch = s.gitBranch || '';
218
+ tb.appendChild(tr);
219
+ }
220
+ $('#recentEmpty').hidden = recent.length > 0;
221
+ // Pagination footer
222
+ const total = state.recentTotal;
223
+ const limit = state.recentLimit;
224
+ const offset = state.recentOffset;
225
+ const pageNum = Math.floor(offset / limit) + 1;
226
+ const pageTotal = Math.max(1, Math.ceil(total / limit));
227
+ $('#recentMeta').textContent = total
228
+ ? `${total} total · sorted by jsonl mtime, excluding live`
229
+ : 'no recent sessions';
230
+ if (total > limit) {
231
+ $('#recentPagination').hidden = false;
232
+ $('#recentPageNum').textContent = pageNum;
233
+ $('#recentPageTotal').textContent = pageTotal;
234
+ $('#recentTotal').textContent = total;
235
+ $('#recentPrevBtn').disabled = offset === 0;
236
+ $('#recentNextBtn').disabled = offset + limit >= total;
237
+ } else {
238
+ $('#recentPagination').hidden = true;
239
+ }
240
+ markRendered('recentTable');
241
+ }
242
+
243
+ /* ─────────────────────────────────────────────────────────────
244
+ Render: favorites
245
+ ───────────────────────────────────────────────────────────── */
246
+ function renderFavorites() {
247
+ const tb = $('#favoritesTable tbody');
248
+ tb.innerHTML = '';
249
+ const list = Object.values(state.favorites).sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
250
+ for (const f of list) {
251
+ const liveMatch = state.sessions.find((s) => s.sessionId === f.sessionId);
252
+ const title = liveMatch?.title || f.title;
253
+ const cwd = liveMatch?.cwd || f.cwd;
254
+ const branch = f.gitBranch;
255
+ const actions = liveMatch
256
+ ? `<button class="action small primary" data-focus="${escapeHtml(f.sessionId)}" title="raise the wt window">Focus</button>
257
+ <button class="action small" data-resume="${escapeHtml(f.sessionId)}" data-cwd="${escapeHtml(cwd)}" title="claude --resume in a fresh wt window">Resume new ↗</button>`
258
+ : `<button class="action small primary" data-continue="${escapeHtml(f.sessionId)}" data-cwd="${escapeHtml(cwd || '')}" ${cwd ? '' : 'disabled'} title="claude --resume in a fresh wt window">Continue ↗</button>`;
259
+ const tr = document.createElement('tr');
260
+ tr.innerHTML = `
261
+ <td>
262
+ <div class="title-cell">
263
+ <div class="title-row">
264
+ <span class="primary" title="${escapeHtml(title || '')}">${escapeHtml(title || '(no title)')}</span>
265
+ ${starButtonHtml(f.sessionId, true)}
266
+ </div>
267
+ <div class="secondary" title="${escapeHtml(f.sessionId)}">
268
+ ${escapeHtml(f.sessionId.slice(0, 8))}${liveMatch ? ` · <span style="color:var(--green);">live</span>` : ''}
269
+ </div>
270
+ </div>
271
+ </td>
272
+ <td><div class="path-cell" title="${escapeHtml(cwd || '')}">${escapeHtml(cwd || '')}</div></td>
273
+ <td>${branch ? `<span class="branch-tag">${escapeHtml(branch)}</span>` : '<span class="muted-text">—</span>'}</td>
274
+ <td class="num" title="${escapeHtml(fmtTime(f.addedAt))}">${escapeHtml(fmtAgo(f.addedAt))}</td>
275
+ <td><div class="row-actions">${actions}</div></td>
276
+ `;
277
+ tr.dataset.cwd = cwd || '';
278
+ tr.dataset.title = title || '';
279
+ tb.appendChild(tr);
280
+ }
281
+ const count = list.length;
282
+ $('#favoritesEmpty').style.display = count === 0 ? 'block' : 'none';
283
+ $('#favoritesTable').style.display = count === 0 ? 'none' : 'table';
284
+ $('#favoritesMeta').textContent = count
285
+ ? `${count} pinned`
286
+ : 'click ☆ on any row to pin sessions here';
287
+ markRendered('favoritesTable');
288
+ }
289
+
290
+ /* ─────────────────────────────────────────────────────────────
291
+ Render: snapshot
292
+ ───────────────────────────────────────────────────────────── */
87
293
 
88
294
  function renderSnapshot() {
89
295
  const snap = state.snapshot;
90
296
  if (!snap) {
91
- $('#snapshotMeta').textContent = 'no snapshot yet';
297
+ $('#snapshotMeta').textContent = 'no snapshot saved yet';
92
298
  $('#snapshotPreview').textContent = '';
93
299
  return;
94
300
  }
95
301
  $('#snapshotMeta').textContent =
96
- `${snap.sessions.length} session(s) taken ${fmtAgo(snap.takenAt)} (${fmtTime(snap.takenAt)})`;
97
- const lines = snap.sessions.map((s) =>
98
- `${(s.title || s.sessionId.slice(0,8)).padEnd(40).slice(0,40)} ${s.cwd}`
99
- );
100
- $('#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');
101
307
 
102
308
  const sel = $('#historySelect');
103
309
  sel.innerHTML = '<option value="">history…</option>' +
104
- 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('');
105
313
  }
106
314
 
107
- // ---- workspaces render ----
315
+ /* ─────────────────────────────────────────────────────────────
316
+ Render: workspaces
317
+ ───────────────────────────────────────────────────────────── */
108
318
 
109
319
  function renderWorkspaces() {
110
- const ul = $('#workspaceList');
111
- ul.innerHTML = '';
320
+ const grid = $('#workspaceList');
321
+ grid.innerHTML = '';
112
322
  if (state.workspaces.length === 0) {
113
- 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>';
114
324
  }
115
325
  for (const w of state.workspaces) {
116
- const repoTags = w.repos.map((r) =>
117
- `<span class="tag ${r.cloned ? 'ok' : ''}">${escapeHtml(r.name)}${r.cloned ? ' ✓' : ''}</span>`
118
- ).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('');
119
329
  const card = document.createElement('div');
120
330
  card.className = 'workspace-card' + (w.inUse ? ' in-use' : '');
121
331
  card.innerHTML = `
122
- <div>
123
- <div class="name">${escapeHtml(w.name)}
124
- ${w.inUse ? `<span class="tag warn">in use × ${w.sessionsHere.length}</span>` : '<span class="tag ok">free</span>'}
125
- </div>
126
- <div class="repos">${escapeHtml(w.path)}</div>
127
- <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>
128
335
  </div>
336
+ <div class="ws-path">${escapeHtml(w.path)}</div>
337
+ <div class="ws-repos">${repos}</div>
129
338
  `;
130
- ul.appendChild(card);
339
+ grid.appendChild(card);
131
340
  }
132
341
 
133
342
  const sel = $('#workspaceSelect');
134
- sel.innerHTML = '<option value="">(auto — find or create unused)</option>' +
343
+ sel.innerHTML = '<option value="">auto — find or create unused</option>' +
135
344
  state.workspaces.filter((w) => !w.inUse).map((w) =>
136
345
  `<option value="${escapeHtml(w.name)}">${escapeHtml(w.name)}</option>`
137
346
  ).join('');
347
+
348
+ if (state.config) $('#workDirDisplay').textContent = state.config.workDir;
138
349
  }
139
350
 
140
- // ---- repo picker render (for "new session") ----
351
+ /* ─────────────────────────────────────────────────────────────
352
+ Render: repo picker
353
+ ───────────────────────────────────────────────────────────── */
141
354
 
142
355
  function renderRepoPicker() {
143
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
+ }
144
362
  root.innerHTML = '';
145
- for (const r of (state.config?.repos || [])) {
146
- const id = `repo_${r.name}`;
363
+ for (const r of repos) {
147
364
  const chip = document.createElement('label');
148
- chip.className = 'repo-chip' + (r.defaultSelected ? ' checked' : '');
149
- 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)}`;
150
367
  chip.querySelector('input').addEventListener('change', (e) => {
151
368
  chip.classList.toggle('checked', e.target.checked);
152
369
  });
@@ -154,7 +371,9 @@ function renderRepoPicker() {
154
371
  }
155
372
  }
156
373
 
157
- // ---- config form render ----
374
+ /* ─────────────────────────────────────────────────────────────
375
+ Render: config form
376
+ ───────────────────────────────────────────────────────────── */
158
377
 
159
378
  function renderConfig() {
160
379
  if (!state.config) return;
@@ -168,10 +387,12 @@ function renderConfig() {
168
387
  $('#cfgBrowserMode').value =
169
388
  state.config.browserMode ||
170
389
  (state.config.autoOpenBrowser === false ? 'none' : 'app');
390
+
171
391
  const termSel = $('#cfgTerminal');
172
392
  termSel.innerHTML = (state.terminals || []).map((t) =>
173
- `<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>`
174
394
  ).join('');
395
+
175
396
  $('#cfgFinderPrompt').value = state.config.finderPrompt || '';
176
397
 
177
398
  const tb = $('#reposTable tbody');
@@ -179,10 +400,10 @@ function renderConfig() {
179
400
  (state.config.repos || []).forEach((r, idx) => {
180
401
  const tr = document.createElement('tr');
181
402
  tr.innerHTML = `
182
- <td><input type="text" value="${escapeHtml(r.name)}" data-field="name" data-idx="${idx}" style="width:140px;" /></td>
183
- <td><input type="text" value="${escapeHtml(r.url)}" data-field="url" data-idx="${idx}" style="width:100%;" /></td>
184
- <td style="text-align:center;"><input type="checkbox" data-field="defaultSelected" data-idx="${idx}" ${r.defaultSelected ? 'checked' : ''} /></td>
185
- <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>
186
407
  `;
187
408
  tb.appendChild(tr);
188
409
  });
@@ -213,14 +434,29 @@ function readConfigFromForm() {
213
434
  };
214
435
  }
215
436
 
216
- // ---- data fetching ----
437
+ /* ─────────────────────────────────────────────────────────────
438
+ Header + footer status
439
+ ───────────────────────────────────────────────────────────── */
217
440
 
218
- async function loadSessions() {
219
- const r = await api('GET', '/api/sessions');
220
- state.sessions = r.sessions;
221
- 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';
449
+ }
450
+ function tickClock() {
451
+ const t = new Date().toLocaleTimeString(undefined, { hour12: false });
452
+ const el = $('#hdTime');
453
+ if (el) el.textContent = t;
222
454
  }
223
455
 
456
+ /* ─────────────────────────────────────────────────────────────
457
+ Loaders
458
+ ───────────────────────────────────────────────────────────── */
459
+
224
460
  async function loadConfig() {
225
461
  const [cfg, terminals] = await Promise.all([
226
462
  api('GET', '/api/config'),
@@ -230,10 +466,56 @@ async function loadConfig() {
230
466
  state.terminals = terminals.terminals;
231
467
  renderConfig();
232
468
  renderRepoPicker();
233
- $('#serverInfo').textContent =
234
- `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 */ }
235
493
  }
236
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
+ }
237
519
  async function loadSnapshot() {
238
520
  const r = await api('GET', '/api/snapshot');
239
521
  state.snapshot = r.snapshot;
@@ -241,72 +523,258 @@ async function loadSnapshot() {
241
523
  state.history = h.history;
242
524
  renderSnapshot();
243
525
  }
244
-
245
526
  async function loadWorkspaces() {
246
527
  const r = await api('GET', '/api/workspaces');
247
528
  state.workspaces = r.workspaces;
248
529
  renderWorkspaces();
249
530
  }
250
-
251
531
  async function refreshAll() {
252
- await Promise.all([loadSessions(), 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
+ }
253
645
  }
254
646
 
255
- // ---- event wiring ----
647
+ /* ─────────────────────────────────────────────────────────────
648
+ Wiring
649
+ ───────────────────────────────────────────────────────────── */
256
650
 
257
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
+
258
662
  $('#refreshBtn').onclick = () => refreshAll().then(() => toast('refreshed'));
259
663
 
260
- $('#autoRefresh').onchange = (e) => {
261
- if (e.target.checked) startAutoRefresh();
262
- 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(() => {});
263
722
  };
264
723
 
724
+ /* live sessions actions */
265
725
  $('#sessionsTable').addEventListener('click', async (ev) => {
726
+ if (ev.target.closest('button[data-star]')) return;
266
727
  const focusBtn = ev.target.closest('button[data-focus]');
267
728
  if (focusBtn) {
268
729
  const sessionId = focusBtn.dataset.focus;
269
730
  focusBtn.disabled = true;
270
731
  try {
271
732
  const r = await api('POST', `/api/sessions/${sessionId}/focus`);
272
- if (r.ok && r.activated) {
273
- toast(`focused: ${r.windowTitle || r.windowProcess || sessionId.slice(0,8)}`);
274
- } else if (r.ok) {
275
- toast(`window found but Windows blocked focus (${r.windowProcess}); try clicking the wt taskbar icon`, 'error');
276
- } else {
277
- toast(`no window for pid — chain: ${(r.chain||[]).map(c=>c.name).join('→')}`, 'error');
278
- }
279
- } catch (e) {
280
- toast(e.message, 'error');
281
- } finally {
282
- focusBtn.disabled = false;
283
- }
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; }
284
738
  return;
285
739
  }
286
- const btn = ev.target.closest('button[data-resume]');
740
+ const resumeBtn = ev.target.closest('button[data-resume]');
741
+ if (!resumeBtn) return;
742
+ const sessionId = resumeBtn.dataset.resume;
743
+ const cwd = resumeBtn.dataset.cwd;
744
+ resumeBtn.disabled = true;
745
+ try {
746
+ await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
747
+ toast(`opening wt · ${sessionId.slice(0, 8)}…`);
748
+ } catch (e) { toast(e.message, 'error'); }
749
+ finally { resumeBtn.disabled = false; }
750
+ });
751
+
752
+ /* recent continue */
753
+ $('#recentTable').addEventListener('click', async (ev) => {
754
+ if (ev.target.closest('button[data-star]')) return;
755
+ const btn = ev.target.closest('button[data-continue]');
287
756
  if (!btn) return;
288
- const sessionId = btn.dataset.resume;
757
+ const sessionId = btn.dataset.continue;
289
758
  const cwd = btn.dataset.cwd;
290
759
  btn.disabled = true;
291
760
  try {
292
761
  await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
293
- toast(`opening wt for ${sessionId.slice(0,8)}…`);
294
- } catch (e) {
295
- toast(e.message, 'error');
296
- } finally {
297
- btn.disabled = false;
298
- }
762
+ toast(`continuing · ${sessionId.slice(0, 8)}…`);
763
+ setTimeout(() => loadSessions().catch(() => {}), 3000);
764
+ setTimeout(() => loadRecent().catch(() => {}), 4000);
765
+ } catch (e) { toast(e.message, 'error'); }
766
+ finally { btn.disabled = false; }
299
767
  });
300
768
 
769
+ /* finder */
301
770
  $('#finderBtn').onclick = async () => {
302
771
  try {
303
772
  await api('POST', '/api/sessions/finder');
304
773
  toast('finder session launching in a new wt window');
305
- } catch (e) {
306
- toast(e.message, 'error');
307
- }
774
+ } catch (e) { toast(e.message, 'error'); }
308
775
  };
309
776
 
777
+ /* snapshot */
310
778
  $('#snapshotSaveBtn').onclick = async () => {
311
779
  try {
312
780
  const r = await api('POST', '/api/snapshot');
@@ -314,12 +782,9 @@ function wireUp() {
314
782
  const h = await api('GET', '/api/snapshot/history');
315
783
  state.history = h.history;
316
784
  renderSnapshot();
317
- toast(`saved snapshot with ${r.snapshot.sessions.length} session(s)`);
318
- } catch (e) {
319
- toast(e.message, 'error');
320
- }
785
+ toast(`saved · ${r.snapshot.sessions.length} session(s)`);
786
+ } catch (e) { toast(e.message, 'error'); }
321
787
  };
322
-
323
788
  $('#snapshotRestoreBtn').onclick = async () => {
324
789
  const snap = state.snapshot;
325
790
  if (!snap || !snap.sessions.length) return toast('no sessions in snapshot', 'error');
@@ -327,11 +792,8 @@ function wireUp() {
327
792
  try {
328
793
  const r = await api('POST', '/api/snapshot/restore');
329
794
  toast(`launched ${r.restored.launched.length} / ${r.count}`);
330
- } catch (e) {
331
- toast(e.message, 'error');
332
- }
795
+ } catch (e) { toast(e.message, 'error'); }
333
796
  };
334
-
335
797
  $('#historyRestoreBtn').onclick = async () => {
336
798
  const file = $('#historySelect').value;
337
799
  if (!file) return toast('pick a history snapshot first', 'error');
@@ -339,11 +801,10 @@ function wireUp() {
339
801
  try {
340
802
  const r = await api('POST', '/api/snapshot/restore', { file });
341
803
  toast(`launched ${r.restored.launched.length} / ${r.count}`);
342
- } catch (e) {
343
- toast(e.message, 'error');
344
- }
804
+ } catch (e) { toast(e.message, 'error'); }
345
805
  };
346
806
 
807
+ /* new session */
347
808
  $('#newSessionBtn').onclick = async () => {
348
809
  const repos = $$('#repoPicker input:checked').map((i) => i.dataset.repo);
349
810
  if (repos.length === 0) return toast('select at least one repo', 'error');
@@ -358,8 +819,8 @@ function wireUp() {
358
819
  const ws = result.workspace;
359
820
  const summary = (result.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
360
821
  $('#newSessionResult').textContent =
361
- `launched in ${ws.path}${result.created ? ' (newly created)' : ''} — ${summary}`;
362
- toast(`launched new session in ${ws.name}`);
822
+ `launched in ${ws.path}${result.created ? ' · newly created' : ''} — ${summary}`;
823
+ toast(`launched · ${ws.name}`);
363
824
  } else {
364
825
  $('#newSessionResult').textContent = `error: ${result.error}`;
365
826
  toast(result.error || 'new session failed', 'error');
@@ -368,11 +829,10 @@ function wireUp() {
368
829
  } catch (e) {
369
830
  $('#newSessionResult').textContent = `error: ${e.message}`;
370
831
  toast(e.message, 'error');
371
- } finally {
372
- btn.disabled = false;
373
- }
832
+ } finally { btn.disabled = false; }
374
833
  };
375
834
 
835
+ /* config save */
376
836
  $('#saveConfigBtn').onclick = async () => {
377
837
  const next = readConfigFromForm();
378
838
  try {
@@ -380,19 +840,16 @@ function wireUp() {
380
840
  state.config = cfg;
381
841
  renderConfig();
382
842
  renderRepoPicker();
383
- $('#configSavedAt').textContent = `saved at ${new Date().toLocaleTimeString()}`;
843
+ renderHeaderStatus();
844
+ $('#configSavedAt').textContent = `saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`;
384
845
  toast('config saved');
385
846
  await loadWorkspaces();
386
- } catch (e) {
387
- toast(e.message, 'error');
388
- }
847
+ } catch (e) { toast(e.message, 'error'); }
389
848
  };
390
-
391
849
  $('#addRepoBtn').onclick = () => {
392
850
  state.config.repos.push({ name: '', url: '', defaultSelected: false });
393
851
  renderConfig();
394
852
  };
395
-
396
853
  $('#reposTable').addEventListener('click', (ev) => {
397
854
  const rm = ev.target.closest('button[data-remove-repo]');
398
855
  if (!rm) return;
@@ -402,154 +859,36 @@ function wireUp() {
402
859
  });
403
860
  }
404
861
 
405
- // ---- auto refresh ----
406
-
407
862
  function startAutoRefresh() {
408
- stopAutoRefresh();
863
+ if (state.autoTimer) clearInterval(state.autoTimer);
409
864
  state.autoTimer = setInterval(() => {
410
865
  loadSessions().catch(() => {});
866
+ loadRecent().catch(() => {});
411
867
  loadSnapshot().catch(() => {});
412
868
  }, 5000);
413
869
  }
414
- function stopAutoRefresh() {
415
- if (state.autoTimer) { clearInterval(state.autoTimer); state.autoTimer = null; }
416
- }
417
-
418
- // ---- NDJSON streaming for /api/sessions/new ----
419
-
420
- function resetProgress(repoNames) {
421
- const root = $('#newSessionProgress');
422
- root.innerHTML = '';
423
- for (const r of repoNames) {
424
- const el = document.createElement('div');
425
- el.className = 'progress-item';
426
- el.dataset.repo = r;
427
- el.innerHTML = `
428
- <div class="head">
429
- <span class="name">${escapeHtml(r)}</span>
430
- <span class="phase">queued</span>
431
- <span class="pct"></span>
432
- </div>
433
- <div class="progress-bar"><div class="fill"></div></div>
434
- <div class="detail"></div>
435
- `;
436
- root.appendChild(el);
437
- }
438
- }
439
870
 
440
- function progressItem(repo) {
441
- return document.querySelector(`#newSessionProgress .progress-item[data-repo="${CSS.escape(repo)}"]`);
442
- }
443
-
444
- function setProgress(repo, { phase, percent, detail, state, indeterminate } = {}) {
445
- const el = progressItem(repo);
446
- if (!el) return;
447
- if (state) {
448
- el.classList.remove('ok', 'error');
449
- if (state === 'ok' || state === 'error') el.classList.add(state);
450
- }
451
- if (phase != null) el.querySelector('.phase').textContent = phase;
452
- if (percent != null) {
453
- el.querySelector('.pct').textContent = `${percent}%`;
454
- el.querySelector('.fill').style.width = `${percent}%`;
455
- el.querySelector('.fill').classList.remove('indeterminate');
456
- }
457
- if (indeterminate) {
458
- el.querySelector('.fill').classList.add('indeterminate');
459
- el.querySelector('.pct').textContent = '';
460
- }
461
- if (detail != null) el.querySelector('.detail').textContent = detail;
462
- }
463
-
464
- async function streamNewSession(body) {
465
- const res = await fetch('/api/sessions/new', {
466
- method: 'POST',
467
- headers: { 'Content-Type': 'application/json' },
468
- body: JSON.stringify(body),
469
- });
470
- if (!res.ok && res.headers.get('content-type')?.startsWith('application/json')) {
471
- const j = await res.json();
472
- throw new Error(j.error || `HTTP ${res.status}`);
473
- }
474
- const reader = res.body.getReader();
475
- const decoder = new TextDecoder();
476
- let buf = '';
477
- let final = null;
478
- while (true) {
479
- const { done, value } = await reader.read();
480
- if (done) break;
481
- buf += decoder.decode(value, { stream: true });
482
- const lines = buf.split('\n');
483
- buf = lines.pop();
484
- for (const line of lines) {
485
- if (!line.trim()) continue;
486
- let event;
487
- try { event = JSON.parse(line); } catch { continue; }
488
- handleNewSessionEvent(event);
489
- if (event.type === 'done') final = event;
490
- }
491
- }
492
- if (buf.trim()) {
493
- try {
494
- const event = JSON.parse(buf);
495
- handleNewSessionEvent(event);
496
- if (event.type === 'done') final = event;
497
- } catch {}
498
- }
499
- return final || { success: false, error: 'stream ended unexpectedly' };
500
- }
501
-
502
- function handleNewSessionEvent(ev) {
503
- switch (ev.type) {
504
- case 'workspace':
505
- $('#newSessionResult').textContent =
506
- `workspace: ${ev.workspace.path}${ev.created ? ' (new)' : ''}`;
507
- break;
508
- case 'clone-start':
509
- setProgress(ev.repo, { phase: 'starting', indeterminate: true });
510
- break;
511
- case 'clone-progress':
512
- setProgress(ev.repo, {
513
- phase: ev.phase,
514
- percent: ev.percent,
515
- detail: ev.detail || (ev.current != null ? `${ev.current}/${ev.total}` : ''),
516
- });
517
- break;
518
- case 'clone-end':
519
- if (ev.ok) {
520
- setProgress(ev.repo, {
521
- phase: ev.action || 'done',
522
- percent: 100,
523
- detail: ev.path || '',
524
- state: 'ok',
525
- });
526
- } else {
527
- setProgress(ev.repo, {
528
- phase: 'error',
529
- detail: ev.error,
530
- state: 'error',
531
- });
532
- }
533
- break;
534
- case 'launched':
535
- $('#newSessionResult').textContent =
536
- `terminal launching — pid ${ev.launched.pid} (${ev.launched.terminal})`;
537
- break;
538
- case 'done':
539
- // handled by caller
540
- break;
541
- }
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();
542
875
  }
543
876
 
544
- // ---- boot ----
877
+ /* ─────────────────────────────────────────────────────────────
878
+ Boot
879
+ ───────────────────────────────────────────────────────────── */
545
880
 
546
881
  (async () => {
882
+ restoreSidebar();
547
883
  wireUp();
548
884
  try {
549
885
  await loadConfig();
550
886
  await refreshAll();
887
+ selectTab(state.activeTab);
551
888
  startAutoRefresh();
889
+ tickClock();
890
+ state.clockTimer = setInterval(tickClock, 1000);
552
891
  } catch (e) {
553
- toast('initial load failed: ' + e.message, 'error');
892
+ toast('initial load failed · ' + e.message, 'error');
554
893
  }
555
894
  })();