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