claude-code-kanban 7.3.0 → 7.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/README.md +5 -3
- package/cli.js +95 -29
- package/lib/panes.js +26 -5
- package/lib/parsers.js +15 -1
- package/lib/retention.js +25 -0
- package/lib/scratchpad-dir.js +19 -2
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +33 -18
- package/lib/task-dir.js +26 -0
- package/lib/terminal.js +21 -22
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -5
- package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +80 -0
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +2 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -3
- package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +74 -0
- package/public/app.js +1180 -1099
- package/public/index.html +23 -38
- package/public/style.css +199 -37
- package/server.js +171 -159
- package/plugin/plugins/claude-code-kanban/monitors.json +0 -8
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +0 -82
- package/plugin/plugins/claude-code-kanban/skills/follow/SKILL.md +0 -47
package/public/app.js
CHANGED
|
@@ -2,23 +2,69 @@
|
|
|
2
2
|
// Set by the server in index.html: null for the default Claude dir, a per-dir prefix otherwise.
|
|
3
3
|
const STORAGE_NS = window.__STORAGE_NS__ ? `${window.__STORAGE_NS__}:` : '';
|
|
4
4
|
// Theme is hub-wide (echoed to every app via hub:theme), so it stays shared across config dirs.
|
|
5
|
-
const
|
|
5
|
+
const THEME_KEY = 'theme';
|
|
6
|
+
const COLOR_THEME_KEY = 'color-theme';
|
|
7
|
+
const GLOBAL_KEYS = new Set([THEME_KEY, COLOR_THEME_KEY]);
|
|
8
|
+
// localStorage names are kept as they are: renaming one drops what users saved under it.
|
|
9
|
+
const MESSAGE_PANEL_OPEN_KEY = 'message-panel-open';
|
|
10
|
+
const MESSAGE_PANEL_FULL_KEY = 'message-panel-full';
|
|
11
|
+
const ACTIVITY_FILTER_KEY = 'activityFilter';
|
|
12
|
+
const COLLAPSED_GROUPS_KEY = 'collapsedGroups';
|
|
13
|
+
const PINNED_SESSIONS_KEY = 'pinned-sessions';
|
|
14
|
+
const STICKY_SESSIONS_KEY = 'sticky-sessions';
|
|
15
|
+
const GROUP_PINNED_KEY = 'groupPinnedSessions';
|
|
16
|
+
const SIDEBAR_COLLAPSED_KEY = 'sidebar-collapsed';
|
|
17
|
+
const SIDEBAR_WIDTH_KEY = 'sidebar-width';
|
|
18
|
+
const PINNED_MESSAGES_PREFIX = 'pinned-messages-';
|
|
19
|
+
const pinsKey = (sessionId) => PINNED_MESSAGES_PREFIX + sessionId;
|
|
20
|
+
const modalFullscreenKey = (modalId) => `modal-fullscreen-${modalId}`;
|
|
21
|
+
const sectionCollapsedKey = (containerId) => `${containerId}Collapsed`;
|
|
22
|
+
const panelWidthKey = (panelId) => `${panelId}-width`;
|
|
6
23
|
const nsKey = (k) => (GLOBAL_KEYS.has(k) ? k : STORAGE_NS + k);
|
|
7
24
|
const { normalizeProjectPath, isExactProjectPath, isExactProjectFilter, projectMatcher, sessionProjectKey } =
|
|
8
25
|
projectMatch;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
// Storage throws when the browser blocks site data or the quota is full. Saved UI state is
|
|
27
|
+
// optional, so a failed read gives null and a failed write returns false.
|
|
28
|
+
function namespacedStorage(area) {
|
|
29
|
+
const attempt = (fn, fallback) => {
|
|
30
|
+
try {
|
|
31
|
+
return fn(window[area]);
|
|
32
|
+
} catch {
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
keys: () =>
|
|
38
|
+
attempt((s) => {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (let i = 0; i < s.length; i++) {
|
|
41
|
+
const k = s.key(i);
|
|
42
|
+
if (k.startsWith(STORAGE_NS)) out.push(k.slice(STORAGE_NS.length));
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}, []),
|
|
46
|
+
getItem: (k) => attempt((s) => s.getItem(nsKey(k)), null),
|
|
47
|
+
setItem: (k, v) =>
|
|
48
|
+
attempt((s) => {
|
|
49
|
+
s.setItem(nsKey(k), v);
|
|
50
|
+
return true;
|
|
51
|
+
}, false),
|
|
52
|
+
removeItem: (k) => attempt((s) => s.removeItem(nsKey(k))),
|
|
53
|
+
readJson(k, fallback = null) {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(this.getItem(k)) ?? fallback;
|
|
56
|
+
} catch {
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
writeJson(k, v) {
|
|
61
|
+
return this.setItem(k, JSON.stringify(v));
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const store = namespacedStorage('localStorage');
|
|
66
|
+
// Every config dir's board is served on the hub's one kanban origin, so sessionStorage is shared.
|
|
67
|
+
const tabStore = namespacedStorage('sessionStorage');
|
|
22
68
|
|
|
23
69
|
// The baseline "nothing is filtered" state: what resetState() returns to, what updateUrl() omits
|
|
24
70
|
// from the query string, and what decides whether a control tints ember in the header summary.
|
|
@@ -53,6 +99,7 @@ const collapsedProjectGroups = new Set();
|
|
|
53
99
|
const SECTION_GROUPS = '__section_groups__';
|
|
54
100
|
const SECTION_PROJECTS = '__section_projects__';
|
|
55
101
|
const SECTION_SESSIONS = '__section_sessions__';
|
|
102
|
+
const SECTION_OUTSIDE = '__section_outside__';
|
|
56
103
|
// Project paths and session ids in Active-view sidebar order, kept across reloads so live
|
|
57
104
|
// activity does not reshuffle the list. Each holds only what the loaded list still has.
|
|
58
105
|
const PROJECT_ORDER_KEY = 'project-order';
|
|
@@ -117,28 +164,19 @@ const dismissedSessionIds = loadDismissedSessions();
|
|
|
117
164
|
let dismissSaveWarned = false;
|
|
118
165
|
|
|
119
166
|
function loadDismissedSessions() {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return new Map(saved.filter(([, at]) => isDismissFresh(at)));
|
|
123
|
-
} catch {
|
|
124
|
-
return new Map();
|
|
125
|
-
}
|
|
167
|
+
const saved = store.readJson(DISMISSED_KEY, {});
|
|
168
|
+
return new Map(Object.entries(saved).filter(([, at]) => isDismissFresh(at)));
|
|
126
169
|
}
|
|
127
170
|
|
|
128
171
|
function persistDismissedSessions() {
|
|
129
172
|
for (const [id, at] of dismissedSessionIds) if (!isDismissFresh(at)) dismissedSessionIds.delete(id);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
showToast(
|
|
138
|
-
'Browser storage is full: dismissed sessions come back on reload. Storage → Clean Orphaned frees space.',
|
|
139
|
-
'error',
|
|
140
|
-
);
|
|
141
|
-
}
|
|
173
|
+
// The map still works in memory, so only the next reload is affected. Say so once.
|
|
174
|
+
if (!store.writeJson(DISMISSED_KEY, Object.fromEntries(dismissedSessionIds)) && !dismissSaveWarned) {
|
|
175
|
+
dismissSaveWarned = true;
|
|
176
|
+
showToast(
|
|
177
|
+
'Could not save to browser storage: dismissed sessions come back on reload. If storage is full, Storage → Clean Orphaned frees space.',
|
|
178
|
+
'error',
|
|
179
|
+
);
|
|
142
180
|
}
|
|
143
181
|
}
|
|
144
182
|
|
|
@@ -157,7 +195,7 @@ function resetMessageScrollState() {
|
|
|
157
195
|
msgUserScrolledUp = false;
|
|
158
196
|
msgHasMore = false;
|
|
159
197
|
msgLoadingMore = false;
|
|
160
|
-
|
|
198
|
+
setCurrentMessages([]);
|
|
161
199
|
lastMessagesHash = '';
|
|
162
200
|
const btn = document.getElementById('msg-jump-latest');
|
|
163
201
|
if (btn) btn.style.display = 'none';
|
|
@@ -171,7 +209,9 @@ function getUrlState() {
|
|
|
171
209
|
project: params.get('project'),
|
|
172
210
|
owner: params.get('owner'),
|
|
173
211
|
search: params.get('search'),
|
|
174
|
-
messages: params.has('messages')
|
|
212
|
+
messages: params.has('messages')
|
|
213
|
+
? params.get('messages') === '1'
|
|
214
|
+
: store.getItem(MESSAGE_PANEL_OPEN_KEY) === 'true',
|
|
175
215
|
projectView: params.get('projectView'),
|
|
176
216
|
};
|
|
177
217
|
}
|
|
@@ -195,32 +235,23 @@ function updateUrl() {
|
|
|
195
235
|
|
|
196
236
|
const LAST_VIEW_KEY = 'lastView';
|
|
197
237
|
function persistLastView() {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
};
|
|
207
|
-
store.setItem(LAST_VIEW_KEY, JSON.stringify(data));
|
|
208
|
-
} catch (_) {}
|
|
238
|
+
store.writeJson(LAST_VIEW_KEY, {
|
|
239
|
+
view: viewMode,
|
|
240
|
+
session: currentSessionId,
|
|
241
|
+
projectPath: viewMode === 'project' ? currentProjectPath : null,
|
|
242
|
+
// The project filter also lives in the URL, but the hub recreates each iframe at the app's
|
|
243
|
+
// base URL on reload, so the query string alone doesn't survive a hub refresh.
|
|
244
|
+
project: filterProject,
|
|
245
|
+
});
|
|
209
246
|
}
|
|
210
247
|
function loadLastView() {
|
|
211
|
-
|
|
212
|
-
return JSON.parse(store.getItem(LAST_VIEW_KEY)) || null;
|
|
213
|
-
} catch (_) {
|
|
214
|
-
return null;
|
|
215
|
-
}
|
|
248
|
+
return store.readJson(LAST_VIEW_KEY);
|
|
216
249
|
}
|
|
217
250
|
|
|
218
251
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
219
252
|
function resetState() {
|
|
220
253
|
history.replaceState(null, '', window.location.pathname);
|
|
221
|
-
|
|
222
|
-
store.removeItem(LAST_VIEW_KEY);
|
|
223
|
-
} catch (_) {}
|
|
254
|
+
store.removeItem(LAST_VIEW_KEY);
|
|
224
255
|
sessionFilter = FILTER_DEFAULTS.session;
|
|
225
256
|
sessionLimit = SESSION_PAGE;
|
|
226
257
|
filterProject = FILTER_DEFAULTS.project;
|
|
@@ -266,31 +297,52 @@ let lastTasksHash = '';
|
|
|
266
297
|
//#endregion
|
|
267
298
|
|
|
268
299
|
//#region DATA_FETCHING
|
|
300
|
+
function apiPath(strings, ...values) {
|
|
301
|
+
return strings.reduce((out, s, i) => out + encodeURIComponent(values[i - 1]) + s);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function api(path, { method, body, query, headers, cache } = {}) {
|
|
305
|
+
const qs = query ? new URLSearchParams(Object.entries(query).filter(([, v]) => v != null)).toString() : '';
|
|
306
|
+
const json = body != null;
|
|
307
|
+
return fetch(qs ? `${path}?${qs}` : path, {
|
|
308
|
+
method,
|
|
309
|
+
cache,
|
|
310
|
+
headers: json ? { 'Content-Type': 'application/json', ...headers } : headers,
|
|
311
|
+
body: json ? JSON.stringify(body) : undefined,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function getJson(path, fallback = null, opts) {
|
|
316
|
+
try {
|
|
317
|
+
const res = await api(path, opts);
|
|
318
|
+
return res.ok ? await res.json() : fallback;
|
|
319
|
+
} catch {
|
|
320
|
+
return fallback;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
269
324
|
async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
|
|
270
325
|
try {
|
|
271
326
|
const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
|
|
272
327
|
if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
|
|
273
328
|
if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
:
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
`/api/sessions?limit=${sessionLimit}${pinnedParam}${includeParam}${projectParam}${filterParam}`,
|
|
288
|
-
).then((r) => {
|
|
289
|
-
sessionsHasMore = r.headers.get('X-Has-More') === 'true';
|
|
290
|
-
return r.json();
|
|
329
|
+
const recent = filterProject === '__recent__';
|
|
330
|
+
const res = await api('/api/sessions', {
|
|
331
|
+
query: {
|
|
332
|
+
limit: sessionLimit,
|
|
333
|
+
pinned: allPinnedIds.size > 0 ? [...allPinnedIds].join(',') : null,
|
|
334
|
+
// The focused session must come back whatever the filters say: renderSession and the
|
|
335
|
+
// info modal both look it up in `sessions` and bail when it is absent, so a session
|
|
336
|
+
// opened from outside the current filter would leave the view on the previous one.
|
|
337
|
+
include: focusId || null,
|
|
338
|
+
recentHours: recent ? RECENT_PROJECT_HOURS : null,
|
|
339
|
+
project: !recent && filterProject ? filterProject : null,
|
|
340
|
+
filter: sessionFilter === 'active' ? 'active' : null,
|
|
341
|
+
},
|
|
291
342
|
});
|
|
292
|
-
|
|
293
|
-
|
|
343
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
344
|
+
sessionsHasMore = res.headers.get('X-Has-More') === 'true';
|
|
345
|
+
const newSessions = await res.json();
|
|
294
346
|
const sessionsHash = JSON.stringify(newSessions);
|
|
295
347
|
if (sessionsHash !== lastSessionsHash) {
|
|
296
348
|
lastSessionsHash = sessionsHash;
|
|
@@ -315,9 +367,7 @@ function markSessionRead(sessionId) {
|
|
|
315
367
|
if (!session?.unread) return;
|
|
316
368
|
session.unread = false;
|
|
317
369
|
document.querySelector(`.session-item[data-session-id="${CSS.escape(sessionId)}"]`)?.classList.remove('unread');
|
|
318
|
-
|
|
319
|
-
console.error('[markSessionRead]', e),
|
|
320
|
-
);
|
|
370
|
+
api(apiPath`/api/sessions/${sessionId}/read`, { method: 'POST' }).catch((e) => console.error('[markSessionRead]', e));
|
|
321
371
|
}
|
|
322
372
|
|
|
323
373
|
function markOpenSessionRead() {
|
|
@@ -325,7 +375,8 @@ function markOpenSessionRead() {
|
|
|
325
375
|
}
|
|
326
376
|
|
|
327
377
|
async function loadSearchTasks() {
|
|
328
|
-
const newTasks = await
|
|
378
|
+
const newTasks = await getJson('/api/tasks/all');
|
|
379
|
+
if (!newTasks) return;
|
|
329
380
|
const tasksHash = JSON.stringify(newTasks);
|
|
330
381
|
if (tasksHash === lastTasksHash) return;
|
|
331
382
|
lastTasksHash = tasksHash;
|
|
@@ -359,20 +410,70 @@ sessionsList.addEventListener('scroll', loadMoreIfNearEnd, { passive: true });
|
|
|
359
410
|
|
|
360
411
|
// Sessions by id, in the order given, ignoring every sidebar filter — that is what `include` means.
|
|
361
412
|
async function fetchSessionsByIds(ids) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const byId = new Map((await r.json()).map((s) => [s.id, s]));
|
|
366
|
-
return ids.map((id) => byId.get(id)).filter(Boolean);
|
|
367
|
-
} catch (_) {
|
|
368
|
-
return [];
|
|
369
|
-
}
|
|
413
|
+
const rows = await getJson('/api/sessions', [], { query: { limit: 1, include: ids.join(',') } });
|
|
414
|
+
const byId = new Map(rows.map((s) => [s.id, s]));
|
|
415
|
+
return ids.map((id) => byId.get(id)).filter(Boolean);
|
|
370
416
|
}
|
|
371
417
|
|
|
372
418
|
async function fetchSessionById(id) {
|
|
373
419
|
return (await fetchSessionsByIds([id]))[0] || null;
|
|
374
420
|
}
|
|
375
421
|
|
|
422
|
+
// The id may be outside the list the filters asked for. renderSession bails on a session
|
|
423
|
+
// missing from `sessions`, so pull that one row in before opening it; later polls
|
|
424
|
+
// keep it, because fetchSessions pins currentSessionId.
|
|
425
|
+
async function ensureSessionLoaded(id) {
|
|
426
|
+
const loaded = sessions.find((s) => s.id === id);
|
|
427
|
+
if (loaded) return loaded;
|
|
428
|
+
const session = await fetchSessionById(id);
|
|
429
|
+
if (!session) return null;
|
|
430
|
+
const raced = sessions.find((s) => s.id === id);
|
|
431
|
+
if (raced) return raced;
|
|
432
|
+
sessions.push(session);
|
|
433
|
+
return session;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Matches SESSION_SEARCH_MIN in server.js, which answers [] below it.
|
|
437
|
+
const SESSION_SEARCH_MIN = 3;
|
|
438
|
+
const SESSION_SEARCH_DEBOUNCE_MS = 100;
|
|
439
|
+
|
|
440
|
+
// Searches session ids and names in every transcript on the server, so a session outside the
|
|
441
|
+
// sidebar filter is found too. `rows` answers the server rows not in `localRows`, and whether
|
|
442
|
+
// the search for that query is still out; `onFound()` runs when the current query's rows arrive.
|
|
443
|
+
// Only the current query is kept, and a short query drops it, so a later identical query fetches again.
|
|
444
|
+
function sessionSearch(onFound) {
|
|
445
|
+
let current = null;
|
|
446
|
+
let found = null;
|
|
447
|
+
let timer = null;
|
|
448
|
+
const run = async (key) => {
|
|
449
|
+
const ids = await getJson('/api/sessions/search', [], { query: { q: key } });
|
|
450
|
+
if (key !== current) return;
|
|
451
|
+
const rows = ids.length ? await fetchSessionsByIds(ids) : [];
|
|
452
|
+
if (key !== current) return;
|
|
453
|
+
found = rows;
|
|
454
|
+
onFound();
|
|
455
|
+
};
|
|
456
|
+
return {
|
|
457
|
+
rows(query, localRows) {
|
|
458
|
+
const key = query.length < SESSION_SEARCH_MIN ? null : query.toLowerCase();
|
|
459
|
+
// Set once per query: the sidebar re-renders on every live update, and resetting the
|
|
460
|
+
// timer each time would starve the search.
|
|
461
|
+
if (key !== current) {
|
|
462
|
+
clearTimeout(timer);
|
|
463
|
+
current = key;
|
|
464
|
+
found = null;
|
|
465
|
+
if (key) timer = setTimeout(() => run(key), SESSION_SEARCH_DEBOUNCE_MS);
|
|
466
|
+
}
|
|
467
|
+
if (!key) return { rows: [], pending: false };
|
|
468
|
+
if (!found) return { rows: [], pending: true };
|
|
469
|
+
const seen = new Set(localRows.map((s) => s.id));
|
|
470
|
+
return { rows: found.filter((s) => !seen.has(s.id)), pending: false };
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const sidebarSearch = sessionSearch(renderSessions);
|
|
476
|
+
|
|
376
477
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
377
478
|
function handleSearch(query) {
|
|
378
479
|
const wasSearching = !!searchQuery;
|
|
@@ -432,13 +533,14 @@ function fuzzyMatch(text, query) {
|
|
|
432
533
|
|
|
433
534
|
//#endregion
|
|
434
535
|
|
|
536
|
+
//#region SESSION_ACTIVITY
|
|
435
537
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
436
538
|
function toggleSection(containerId, chevronId) {
|
|
437
539
|
const container = document.getElementById(containerId);
|
|
438
540
|
const chevron = document.getElementById(chevronId);
|
|
439
541
|
const collapsed = container.classList.toggle('collapsed');
|
|
440
542
|
chevron.classList.toggle('rotated', collapsed);
|
|
441
|
-
store.setItem(
|
|
543
|
+
store.setItem(sectionCollapsedKey(containerId), collapsed);
|
|
442
544
|
}
|
|
443
545
|
|
|
444
546
|
function isWaitingSession(s) {
|
|
@@ -575,7 +677,7 @@ function setActivityFilter(kind) {
|
|
|
575
677
|
} else {
|
|
576
678
|
toggleActivityKind(kind);
|
|
577
679
|
}
|
|
578
|
-
store.
|
|
680
|
+
store.writeJson(ACTIVITY_FILTER_KEY, [...activityFilter]);
|
|
579
681
|
// active/waiting only make sense with the active session filter on
|
|
580
682
|
const targetFilter = activityFilter.size > 0 ? 'active' : sessionFilter;
|
|
581
683
|
if (targetFilter !== sessionFilter) {
|
|
@@ -598,7 +700,7 @@ async function fetchTasks(sessionId) {
|
|
|
598
700
|
openTerminal(sessionId, terminalOpenMode(sessionId));
|
|
599
701
|
viewMode = 'session';
|
|
600
702
|
document.getElementById('message-toggle')?.style.removeProperty('display');
|
|
601
|
-
const res = await
|
|
703
|
+
const res = await api(apiPath`/api/sessions/${sessionId}`);
|
|
602
704
|
|
|
603
705
|
if (!res.ok) throw new Error(`Failed to fetch tasks: ${res.status}`);
|
|
604
706
|
const newTasks = await res.json();
|
|
@@ -650,14 +752,16 @@ async function fetchTasks(sessionId) {
|
|
|
650
752
|
renderSession();
|
|
651
753
|
}
|
|
652
754
|
}
|
|
755
|
+
//#endregion
|
|
653
756
|
|
|
654
|
-
|
|
757
|
+
//#region TIMINGS
|
|
655
758
|
const WAITING_TTL_MS = 30 * 60 * 1000;
|
|
656
759
|
const AGENT_LOG_MAX = 8;
|
|
657
760
|
const LIVE_INDICATOR_MS = 10 * 1000;
|
|
658
761
|
const JUST_NOW_MS = 60 * 1000;
|
|
659
|
-
|
|
762
|
+
//#endregion
|
|
660
763
|
|
|
764
|
+
//#region AGENT_FETCHING
|
|
661
765
|
function resetAgentState() {
|
|
662
766
|
currentAgents = [];
|
|
663
767
|
currentWaiting = null;
|
|
@@ -668,7 +772,7 @@ function resetAgentState() {
|
|
|
668
772
|
|
|
669
773
|
async function fetchAgents(sessionId) {
|
|
670
774
|
try {
|
|
671
|
-
const res = await
|
|
775
|
+
const res = await api(apiPath`/api/sessions/${sessionId}/agents`);
|
|
672
776
|
if (!res.ok) {
|
|
673
777
|
resetAgentState();
|
|
674
778
|
return;
|
|
@@ -700,7 +804,7 @@ async function fetchProjectView(projectPath) {
|
|
|
700
804
|
viewMode = 'project';
|
|
701
805
|
currentProjectPath = projectPath;
|
|
702
806
|
currentSessionId = null;
|
|
703
|
-
|
|
807
|
+
setCurrentMessages([]);
|
|
704
808
|
lastMessagesHash = '';
|
|
705
809
|
if (messagePanelOpen) toggleMessagePanel();
|
|
706
810
|
document.getElementById('message-toggle')?.style.setProperty('display', 'none');
|
|
@@ -714,19 +818,8 @@ async function fetchProjectView(projectPath) {
|
|
|
714
818
|
|
|
715
819
|
const encoded = btoa(projectPath);
|
|
716
820
|
const [tasksResult, agentResults] = await Promise.all([
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
.catch((e) => {
|
|
720
|
-
console.error('[fetchProjectView] tasks:', e);
|
|
721
|
-
return [];
|
|
722
|
-
}),
|
|
723
|
-
Promise.all(
|
|
724
|
-
activeSessionIds.map((id) =>
|
|
725
|
-
fetch(`/api/sessions/${id}/agents`)
|
|
726
|
-
.then((r) => r.json())
|
|
727
|
-
.catch(() => ({ agents: [] })),
|
|
728
|
-
),
|
|
729
|
-
),
|
|
821
|
+
getJson(apiPath`/api/projects/${encoded}/tasks`, []),
|
|
822
|
+
Promise.all(activeSessionIds.map((id) => getJson(apiPath`/api/sessions/${id}/agents`, { agents: [] }))),
|
|
730
823
|
]);
|
|
731
824
|
currentTasks = tasksResult;
|
|
732
825
|
const seen = new Set();
|
|
@@ -761,11 +854,7 @@ async function refreshProjectAgents() {
|
|
|
761
854
|
const projectSessions = sessions.filter((s) => s.project === currentProjectPath);
|
|
762
855
|
const activeSessionIds = projectSessions.filter((s) => isSessionActive(s) || isAnyPinned(s.id)).map((s) => s.id);
|
|
763
856
|
const agentResults = await Promise.all(
|
|
764
|
-
activeSessionIds.map((id) =>
|
|
765
|
-
fetch(`/api/sessions/${id}/agents`)
|
|
766
|
-
.then((r) => r.json())
|
|
767
|
-
.catch(() => ({ agents: [] })),
|
|
768
|
-
),
|
|
857
|
+
activeSessionIds.map((id) => getJson(apiPath`/api/sessions/${id}/agents`, { agents: [] })),
|
|
769
858
|
);
|
|
770
859
|
const seen = new Set();
|
|
771
860
|
currentAgents = [];
|
|
@@ -790,6 +879,7 @@ async function refreshProjectAgents() {
|
|
|
790
879
|
lastAgentsHash = hash;
|
|
791
880
|
renderAgentFooter();
|
|
792
881
|
}
|
|
882
|
+
//#endregion
|
|
793
883
|
|
|
794
884
|
//#region MESSAGE_PANEL
|
|
795
885
|
function setMessagePanelVisible(open) {
|
|
@@ -819,7 +909,7 @@ function toggleMessagePanel() {
|
|
|
819
909
|
const panel = document.getElementById('message-panel');
|
|
820
910
|
messagePanelOpen = !messagePanelOpen;
|
|
821
911
|
termState.panelHidden = false;
|
|
822
|
-
store.setItem(
|
|
912
|
+
store.setItem(MESSAGE_PANEL_OPEN_KEY, messagePanelOpen);
|
|
823
913
|
panel.classList.toggle('visible', messagePanelOpen);
|
|
824
914
|
document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
|
|
825
915
|
if (messagePanelOpen && currentSessionId) {
|
|
@@ -842,7 +932,7 @@ function applyMessagePanelFull(on) {
|
|
|
842
932
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
843
933
|
function toggleMessagePanelFull() {
|
|
844
934
|
const on = !document.getElementById('message-panel').classList.contains('full-width');
|
|
845
|
-
store.setItem(
|
|
935
|
+
store.setItem(MESSAGE_PANEL_FULL_KEY, on);
|
|
846
936
|
applyMessagePanelFull(on);
|
|
847
937
|
}
|
|
848
938
|
|
|
@@ -854,7 +944,7 @@ function initMessagePanelFull() {
|
|
|
854
944
|
new ResizeObserver(sync).observe(sidebar);
|
|
855
945
|
sync();
|
|
856
946
|
}
|
|
857
|
-
applyMessagePanelFull(store.getItem(
|
|
947
|
+
applyMessagePanelFull(store.getItem(MESSAGE_PANEL_FULL_KEY) === 'true');
|
|
858
948
|
}
|
|
859
949
|
|
|
860
950
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
@@ -862,7 +952,7 @@ async function openSessionWithBookmarks(sessionId) {
|
|
|
862
952
|
if (!messagePanelOpen) {
|
|
863
953
|
const panel = document.getElementById('message-panel');
|
|
864
954
|
messagePanelOpen = true;
|
|
865
|
-
store.setItem(
|
|
955
|
+
store.setItem(MESSAGE_PANEL_OPEN_KEY, 'true');
|
|
866
956
|
panel.classList.add('visible');
|
|
867
957
|
document.getElementById('message-toggle')?.classList.add('active');
|
|
868
958
|
}
|
|
@@ -904,12 +994,12 @@ async function viewAgentLog(agentId) {
|
|
|
904
994
|
agentLogSSE.close();
|
|
905
995
|
agentLogSSE = null;
|
|
906
996
|
}
|
|
907
|
-
agentLogSSE = new EventSource(`/api/sessions/${agentLogMode.sessionId}/agents/${resolvedId}/messages/stream`);
|
|
997
|
+
agentLogSSE = new EventSource(apiPath`/api/sessions/${agentLogMode.sessionId}/agents/${resolvedId}/messages/stream`);
|
|
908
998
|
agentLogSSE.addEventListener('agent-log-update', (e) => {
|
|
909
999
|
if (!agentLogMode || agentLogMode.agentId !== resolvedId) return;
|
|
910
1000
|
try {
|
|
911
1001
|
const data = JSON.parse(e.data);
|
|
912
|
-
|
|
1002
|
+
setCurrentMessages(data.messages);
|
|
913
1003
|
if (messagePanelOpen) renderMessages(data.messages);
|
|
914
1004
|
maybeFollowLatest();
|
|
915
1005
|
} catch (_) {}
|
|
@@ -938,11 +1028,11 @@ async function fetchAgentMessages() {
|
|
|
938
1028
|
if (!agentLogMode) return;
|
|
939
1029
|
const { sessionId, agentId } = agentLogMode;
|
|
940
1030
|
try {
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
if (!agentLogMode || agentLogMode.agentId !== agentId) return;
|
|
945
|
-
|
|
1031
|
+
const data = await getJson(apiPath`/api/sessions/${sessionId}/agents/${agentId}/messages`, null, {
|
|
1032
|
+
query: { limit: 100 },
|
|
1033
|
+
});
|
|
1034
|
+
if (!data || !agentLogMode || agentLogMode.agentId !== agentId) return;
|
|
1035
|
+
setCurrentMessages(data.messages);
|
|
946
1036
|
if (messagePanelOpen) renderMessages(data.messages);
|
|
947
1037
|
maybeFollowLatest();
|
|
948
1038
|
} catch (e) {
|
|
@@ -960,10 +1050,8 @@ function openLiveLatestMessage() {
|
|
|
960
1050
|
|
|
961
1051
|
const MSG_PAGE_LIMIT = 15;
|
|
962
1052
|
|
|
963
|
-
async function fetchSessionMessagesPage(sessionId, { before } = {}) {
|
|
964
|
-
const
|
|
965
|
-
if (before) qs.set('before', before);
|
|
966
|
-
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/messages?${qs}`);
|
|
1053
|
+
async function fetchSessionMessagesPage(sessionId, { before, limit = MSG_PAGE_LIMIT } = {}) {
|
|
1054
|
+
const res = await api(apiPath`/api/sessions/${sessionId}/messages`, { query: { limit, before: before || null } });
|
|
967
1055
|
return res.ok ? res.json() : null;
|
|
968
1056
|
}
|
|
969
1057
|
|
|
@@ -989,18 +1077,16 @@ async function fetchMessages(sessionId) {
|
|
|
989
1077
|
if (hash === lastMessagesHash) return;
|
|
990
1078
|
lastMessagesHash = hash;
|
|
991
1079
|
msgHasMore = data.hasMore !== false;
|
|
992
|
-
|
|
1080
|
+
setCurrentMessages(data.messages);
|
|
993
1081
|
if (messagePanelOpen) renderMessages(data.messages);
|
|
994
1082
|
} else {
|
|
995
1083
|
if (data.messages.length && currentMessages.length) {
|
|
996
1084
|
const lastKnown = currentMessages[currentMessages.length - 1].timestamp;
|
|
997
1085
|
const newMsgs = data.messages.filter((m) => m.timestamp > lastKnown);
|
|
998
1086
|
if (newMsgs.length) {
|
|
999
|
-
|
|
1000
|
-
if (
|
|
1001
|
-
|
|
1002
|
-
msgHasMore = true;
|
|
1003
|
-
}
|
|
1087
|
+
const merged = [...currentMessages, ...newMsgs];
|
|
1088
|
+
if (merged.length > MSG_MAX_LOADED) msgHasMore = true;
|
|
1089
|
+
setCurrentMessages(merged.slice(-MSG_MAX_LOADED));
|
|
1004
1090
|
if (messagePanelOpen) renderMessages(currentMessages);
|
|
1005
1091
|
}
|
|
1006
1092
|
}
|
|
@@ -1027,10 +1113,7 @@ async function loadOlderMessages() {
|
|
|
1027
1113
|
if (data.messages.length) {
|
|
1028
1114
|
loader.remove();
|
|
1029
1115
|
const prevHeight = container.scrollHeight;
|
|
1030
|
-
|
|
1031
|
-
if (currentMessages.length > MSG_MAX_LOADED) {
|
|
1032
|
-
currentMessages = currentMessages.slice(0, MSG_MAX_LOADED);
|
|
1033
|
-
}
|
|
1116
|
+
setCurrentMessages([...data.messages, ...currentMessages].slice(0, MSG_MAX_LOADED));
|
|
1034
1117
|
renderMessages(currentMessages);
|
|
1035
1118
|
container.scrollTop = container.scrollHeight - prevHeight;
|
|
1036
1119
|
}
|
|
@@ -1615,10 +1698,9 @@ function rejectWaitingPlan(inputId) {
|
|
|
1615
1698
|
async function respondWaiting(payload) {
|
|
1616
1699
|
if (!currentSessionId || !currentWaiting?.id) return;
|
|
1617
1700
|
try {
|
|
1618
|
-
const res = await
|
|
1701
|
+
const res = await api(apiPath`/api/sessions/${currentSessionId}/waiting/respond`, {
|
|
1619
1702
|
method: 'POST',
|
|
1620
|
-
|
|
1621
|
-
body: JSON.stringify({ id: currentWaiting.id, ...payload }),
|
|
1703
|
+
body: { id: currentWaiting.id, ...payload },
|
|
1622
1704
|
});
|
|
1623
1705
|
if (res.ok || res.status === 409 || res.status === 410) {
|
|
1624
1706
|
clearWaitingUi();
|
|
@@ -1665,9 +1747,7 @@ function clearWaitingUi() {
|
|
|
1665
1747
|
async function discardWaiting() {
|
|
1666
1748
|
if (!currentSessionId) return;
|
|
1667
1749
|
try {
|
|
1668
|
-
const res = await
|
|
1669
|
-
method: 'POST',
|
|
1670
|
-
});
|
|
1750
|
+
const res = await api(apiPath`/api/sessions/${currentSessionId}/waiting/discard`, { method: 'POST' });
|
|
1671
1751
|
if (res.ok) clearWaitingUi();
|
|
1672
1752
|
} catch (e) {
|
|
1673
1753
|
console.error('[discardWaiting]', e);
|
|
@@ -1792,15 +1872,12 @@ function getPinId(m) {
|
|
|
1792
1872
|
}
|
|
1793
1873
|
|
|
1794
1874
|
function loadPins(sessionId) {
|
|
1795
|
-
|
|
1796
|
-
return JSON.parse(store.getItem(`pinned-messages-${sessionId}`)) || [];
|
|
1797
|
-
} catch {
|
|
1798
|
-
return [];
|
|
1799
|
-
}
|
|
1875
|
+
return readStoredList(pinsKey(sessionId));
|
|
1800
1876
|
}
|
|
1801
1877
|
|
|
1802
1878
|
function savePins(sessionId, pins) {
|
|
1803
|
-
store.
|
|
1879
|
+
if (pins.length) store.writeJson(pinsKey(sessionId), pins);
|
|
1880
|
+
else store.removeItem(pinsKey(sessionId));
|
|
1804
1881
|
}
|
|
1805
1882
|
|
|
1806
1883
|
function isPinned(m) {
|
|
@@ -1931,6 +2008,7 @@ function showPinnedMsgDetail(pinIdx) {
|
|
|
1931
2008
|
autoSizeModal(pinModal, body);
|
|
1932
2009
|
const pinBtn = document.getElementById('msg-detail-pin-btn');
|
|
1933
2010
|
if (pinBtn) pinBtn.classList.add('active');
|
|
2011
|
+
syncMsgDetailPaneBtn();
|
|
1934
2012
|
document.getElementById('msg-detail-modal').classList.add('visible');
|
|
1935
2013
|
mountReplyReview(pin, body, pinModal);
|
|
1936
2014
|
}
|
|
@@ -1966,34 +2044,22 @@ let stickySessionIds = new Set();
|
|
|
1966
2044
|
const deferredPinPlacement = new Set();
|
|
1967
2045
|
|
|
1968
2046
|
function loadPinnedSessions() {
|
|
1969
|
-
|
|
1970
|
-
return new Set(JSON.parse(store.getItem('pinned-sessions')) || []);
|
|
1971
|
-
} catch {
|
|
1972
|
-
return new Set();
|
|
1973
|
-
}
|
|
2047
|
+
return new Set(readStoredList(PINNED_SESSIONS_KEY));
|
|
1974
2048
|
}
|
|
1975
2049
|
|
|
1976
2050
|
function loadStickySessions() {
|
|
1977
|
-
|
|
1978
|
-
return new Set(JSON.parse(store.getItem('sticky-sessions')) || []);
|
|
1979
|
-
} catch {
|
|
1980
|
-
return new Set();
|
|
1981
|
-
}
|
|
2051
|
+
return new Set(readStoredList(STICKY_SESSIONS_KEY));
|
|
1982
2052
|
}
|
|
1983
2053
|
|
|
1984
2054
|
function savePinnedSessions() {
|
|
1985
|
-
store.
|
|
1986
|
-
store.
|
|
2055
|
+
store.writeJson(PINNED_SESSIONS_KEY, [...pinnedSessionIds]);
|
|
2056
|
+
store.writeJson(STICKY_SESSIONS_KEY, [...stickySessionIds]);
|
|
1987
2057
|
}
|
|
1988
2058
|
|
|
1989
2059
|
// Mirror pin state to server so it can be queried by the CLI. UI remains source of truth for itself.
|
|
1990
2060
|
function offloadSessionPin(sessionId) {
|
|
1991
2061
|
const state = getSessionPinState(sessionId);
|
|
1992
|
-
|
|
1993
|
-
method: 'POST',
|
|
1994
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1995
|
-
body: JSON.stringify({ id: sessionId, state }),
|
|
1996
|
-
}).catch(() => {});
|
|
2062
|
+
api('/api/session/pin', { method: 'POST', body: { id: sessionId, state } }).catch(() => {});
|
|
1997
2063
|
}
|
|
1998
2064
|
|
|
1999
2065
|
function clearSessionPin(sessionId) {
|
|
@@ -2176,33 +2242,27 @@ function highlightSelectedMsg() {
|
|
|
2176
2242
|
if (el) el.classList.add('selected');
|
|
2177
2243
|
}
|
|
2178
2244
|
|
|
2179
|
-
function
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2245
|
+
function msgDetailTitle(m) {
|
|
2246
|
+
if (m.type === 'tool_use') return m.tool;
|
|
2247
|
+
if (m.type === 'teammate') return m.teammateId || 'Teammate';
|
|
2248
|
+
return m.type === 'assistant' ? 'Claude' : m.agentMessage ? m.systemLabel : m.systemLabel ? 'System' : 'User';
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
// The message dialog and a message pane render a message the same way.
|
|
2252
|
+
function msgDetailContent(m) {
|
|
2187
2253
|
if (m.type === 'tool_use') {
|
|
2188
|
-
document.getElementById('msg-detail-title').textContent = m.tool;
|
|
2189
2254
|
const fullText = m.fullDetail || m.detail || '';
|
|
2190
2255
|
const descHtml =
|
|
2191
2256
|
m.description && m.description !== fullText
|
|
2192
2257
|
? `<div style="margin-bottom:8px;color:var(--text-secondary);font-size:0.85rem">${escapeHtml(m.description)}</div>`
|
|
2193
2258
|
: '';
|
|
2194
2259
|
let agentExtraHtml = '';
|
|
2195
|
-
const agentBtn = document.getElementById('msg-detail-agent-btn');
|
|
2196
2260
|
if (m.tool === 'Agent' && m.agentId) {
|
|
2197
2261
|
const agentRespText = m.agentLastMessage ? stripAnsi(m.agentLastMessage.trim()) : null;
|
|
2198
2262
|
const agentPromptText = m.agentPrompt || null;
|
|
2199
2263
|
const respHtml = agentRespText ? renderMarkdown(agentRespText) : null;
|
|
2200
2264
|
const promptHtml = agentPromptText ? renderMarkdown(agentPromptText) : null;
|
|
2201
2265
|
agentExtraHtml += renderAgentTabs(promptHtml, respHtml, agentPromptText, agentRespText);
|
|
2202
|
-
agentBtn.style.display = '';
|
|
2203
|
-
agentBtn.dataset.agentId = m.agentId;
|
|
2204
|
-
} else {
|
|
2205
|
-
agentBtn.style.display = 'none';
|
|
2206
2266
|
}
|
|
2207
2267
|
const sendProto = m.tool === 'SendMessage' && m.params?.protocol;
|
|
2208
2268
|
const isFindings = m.tool === 'ReportFindings';
|
|
@@ -2236,7 +2296,7 @@ function showMsgDetail(idx) {
|
|
|
2236
2296
|
}
|
|
2237
2297
|
const answersHtml = m.answerPayload ? renderAnswerPayloadHtml(m.answerPayload) : '';
|
|
2238
2298
|
const toolResultImagesHtml = renderToolResultImagesHtml(m.toolResultImageCount, m.toolUseId);
|
|
2239
|
-
|
|
2299
|
+
return (
|
|
2240
2300
|
mainHtml +
|
|
2241
2301
|
toolParamsHtml +
|
|
2242
2302
|
answersHtml +
|
|
@@ -2244,53 +2304,94 @@ function showMsgDetail(idx) {
|
|
|
2244
2304
|
findingsHtml +
|
|
2245
2305
|
(hasAgentTabs ? '' : toolResultHtml) +
|
|
2246
2306
|
toolResultImagesHtml +
|
|
2247
|
-
agentExtraHtml
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2307
|
+
agentExtraHtml
|
|
2308
|
+
);
|
|
2309
|
+
}
|
|
2310
|
+
if (m.type === 'teammate') {
|
|
2251
2311
|
if (m.isProtocol) {
|
|
2252
|
-
|
|
2312
|
+
return m.protocolData
|
|
2253
2313
|
? renderProtocolDetail(m.protocolData)
|
|
2254
2314
|
: `<div class="teammate-idle-detail"><span class="protocol-label">${escapeHtml(m.protocolLabel || m.protocolType)}</span></div>`;
|
|
2255
|
-
} else {
|
|
2256
|
-
const text = stripAnsi(m.fullText || m.text || '');
|
|
2257
|
-
body.innerHTML = renderMarkdown(text);
|
|
2258
2315
|
}
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2316
|
+
return renderMarkdown(stripAnsi(m.fullText || m.text || ''));
|
|
2317
|
+
}
|
|
2318
|
+
const rawText = stripAnsi(m.fullText || m.text || '');
|
|
2319
|
+
const cmd = m.type === 'user' ? parseCommandMessage(rawText) : null;
|
|
2320
|
+
const userExtras = m.type === 'user' ? renderUserAttachments(m) : '';
|
|
2321
|
+
if (m.compactSummary) return renderMarkdown(m.compactSummary) + userExtras;
|
|
2322
|
+
if (cmd) {
|
|
2323
|
+
const args = parseCommandArgs(rawText) || null;
|
|
2324
|
+
const cleanBody = rawText
|
|
2325
|
+
.replace(/<command-[^>]+>[\s\S]*?<\/command-[^>]+>/g, '')
|
|
2326
|
+
.replace(/<local-command-[^>]+>[\s\S]*?<\/local-command-[^>]+>/g, '')
|
|
2327
|
+
.trim();
|
|
2328
|
+
let cmdHtml = `<code>${escapeHtml(cmd)}${args ? ` ${escapeHtml(args)}` : ''}</code>`;
|
|
2329
|
+
if (cleanBody) cmdHtml += `<div style="margin-top:10px">${renderMarkdown(cleanBody)}</div>`;
|
|
2330
|
+
return cmdHtml + userExtras;
|
|
2331
|
+
}
|
|
2332
|
+
if (rawText) return renderMarkdown(rawText) + userExtras;
|
|
2333
|
+
return userExtras || '<em>No content</em>';
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
// The detail modal holds an index, so a list that shifts under it must move the index too.
|
|
2337
|
+
function setCurrentMessages(msgs) {
|
|
2338
|
+
const shown = currentMessages[currentMsgDetailIdx];
|
|
2339
|
+
currentMessages = msgs;
|
|
2340
|
+
if (!shown) return;
|
|
2341
|
+
const id = getPinId(shown);
|
|
2342
|
+
const same = msgs[currentMsgDetailIdx];
|
|
2343
|
+
const idx = same && getPinId(same) === id ? currentMsgDetailIdx : msgs.findIndex((m) => getPinId(m) === id);
|
|
2344
|
+
currentMsgDetailIdx = idx >= 0 ? idx : null;
|
|
2345
|
+
setMsgDetailMeta(shown, idx >= 0 && msgPosition(idx));
|
|
2346
|
+
syncMsgDetailPaneBtn();
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
const msgPosition = (idx) => `${idx + 1} of ${currentMessages.length}`;
|
|
2350
|
+
|
|
2351
|
+
function showMsgDetail(idx) {
|
|
2352
|
+
currentMsgDetailIdx = idx;
|
|
2353
|
+
document.getElementById('msg-detail-waiting-footer').innerHTML = '';
|
|
2354
|
+
msgHighlightDimmed = false;
|
|
2355
|
+
const m = currentMessages[idx];
|
|
2356
|
+
if (!m) return;
|
|
2357
|
+
highlightSelectedMsg();
|
|
2358
|
+
renderMsgDetail(m, msgPosition(idx));
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// A message outside the loaded log has no index, so it shows like a pinned message, with follow off.
|
|
2362
|
+
function showUnloadedMsgDetail(m) {
|
|
2363
|
+
currentMsgDetailIdx = null;
|
|
2364
|
+
document.getElementById('msg-detail-waiting-footer').innerHTML = '';
|
|
2365
|
+
highlightSelectedMsg();
|
|
2366
|
+
msgDetailFollowLatest = false;
|
|
2367
|
+
renderMsgDetail(m);
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
const msgDetailMeta = (m) => [m.model, formatDate(m.timestamp)].filter(Boolean);
|
|
2371
|
+
|
|
2372
|
+
function setMsgDetailMeta(m, position) {
|
|
2373
|
+
const meta = msgDetailMeta(m);
|
|
2374
|
+
if (position) meta.push(position);
|
|
2375
|
+
document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
function renderMsgDetail(m, position) {
|
|
2379
|
+
const body = document.getElementById('msg-detail-body');
|
|
2380
|
+
document.getElementById('msg-detail-title').textContent = msgDetailTitle(m);
|
|
2381
|
+
body.innerHTML = msgDetailContent(m);
|
|
2382
|
+
const agentBtn = document.getElementById('msg-detail-agent-btn');
|
|
2383
|
+
const agentId = m.type === 'tool_use' && m.tool === 'Agent' ? m.agentId : null;
|
|
2384
|
+
agentBtn.style.display = agentId ? '' : 'none';
|
|
2385
|
+
if (agentId) agentBtn.dataset.agentId = agentId;
|
|
2283
2386
|
const overlay = document.getElementById('msg-detail-modal');
|
|
2284
2387
|
const modal = overlay.querySelector('.modal');
|
|
2285
2388
|
autoSizeModal(modal, body);
|
|
2286
2389
|
setMsgDetailFollow(msgDetailFollowLatest);
|
|
2287
2390
|
|
|
2288
|
-
|
|
2289
|
-
if (m.model) meta.unshift(m.model);
|
|
2290
|
-
meta.push(`${idx + 1} of ${currentMessages.length}`);
|
|
2291
|
-
document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
|
|
2391
|
+
setMsgDetailMeta(m, position);
|
|
2292
2392
|
currentPinDetailId = null;
|
|
2293
2393
|
updateMsgDetailPinState();
|
|
2394
|
+
syncMsgDetailPaneBtn();
|
|
2294
2395
|
overlay.classList.add('visible');
|
|
2295
2396
|
mountReplyReview(m, body, modal);
|
|
2296
2397
|
}
|
|
@@ -2390,12 +2491,12 @@ function _applyModalFullscreen(modalId, on) {
|
|
|
2390
2491
|
function toggleModalFullscreen(modalId) {
|
|
2391
2492
|
const on = !_modalEl(modalId).classList.contains('fullscreen');
|
|
2392
2493
|
_applyModalFullscreen(modalId, on);
|
|
2393
|
-
store.setItem(
|
|
2494
|
+
store.setItem(modalFullscreenKey(modalId), String(on));
|
|
2394
2495
|
}
|
|
2395
2496
|
|
|
2396
2497
|
function loadModalFullscreen() {
|
|
2397
2498
|
_forEachFullscreenModal((modalId) => {
|
|
2398
|
-
if (store.getItem(
|
|
2499
|
+
if (store.getItem(modalFullscreenKey(modalId)) === 'true') _applyModalFullscreen(modalId, true);
|
|
2399
2500
|
});
|
|
2400
2501
|
}
|
|
2401
2502
|
|
|
@@ -2579,6 +2680,16 @@ function sendMessageAddress(peerName) {
|
|
|
2579
2680
|
return `SendMessage(${peerName})`;
|
|
2580
2681
|
}
|
|
2581
2682
|
|
|
2683
|
+
async function copyText(text, what, okMsg = `Copied ${what}`) {
|
|
2684
|
+
const ok = await navigator.clipboard.writeText(text).then(
|
|
2685
|
+
() => true,
|
|
2686
|
+
() => false,
|
|
2687
|
+
);
|
|
2688
|
+
if (!ok) showToast(`Failed to copy ${what}`, 'error');
|
|
2689
|
+
else if (okMsg) showToast(okMsg, 'success');
|
|
2690
|
+
return ok;
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2582
2693
|
async function copyWithFeedback(text, btn) {
|
|
2583
2694
|
if (btn.dataset.copying) return;
|
|
2584
2695
|
try {
|
|
@@ -2592,6 +2703,7 @@ async function copyWithFeedback(text, btn) {
|
|
|
2592
2703
|
}, 1500);
|
|
2593
2704
|
} catch (e) {
|
|
2594
2705
|
console.error('Failed to copy:', e);
|
|
2706
|
+
showToast('Failed to copy', 'error');
|
|
2595
2707
|
}
|
|
2596
2708
|
}
|
|
2597
2709
|
|
|
@@ -2707,25 +2819,25 @@ function renderAnswerPayloadHtml(answerPayload) {
|
|
|
2707
2819
|
})
|
|
2708
2820
|
.join('');
|
|
2709
2821
|
return `<div style="margin-top:6px">
|
|
2710
|
-
<div
|
|
2822
|
+
<div class="tool-detail-label">${escapeHtml(q)}</div>
|
|
2711
2823
|
<ul style="margin:2px 0 0 16px;padding:0">${items}</ul>
|
|
2712
2824
|
</div>`;
|
|
2713
2825
|
})
|
|
2714
2826
|
.join('');
|
|
2715
|
-
return `<div
|
|
2716
|
-
<div
|
|
2827
|
+
return `<div class="tool-detail-section-wide">
|
|
2828
|
+
<div class="tool-detail-heading">Answers</div>
|
|
2717
2829
|
${rows}
|
|
2718
2830
|
</div>`;
|
|
2719
2831
|
}
|
|
2720
2832
|
|
|
2721
2833
|
//#region FINDINGS
|
|
2722
2834
|
const FINDING_VERDICT_COLORS = {
|
|
2723
|
-
CONFIRMED: 'var(--danger
|
|
2724
|
-
PLAUSIBLE: 'var(--warning
|
|
2835
|
+
CONFIRMED: 'var(--danger)',
|
|
2836
|
+
PLAUSIBLE: 'var(--warning)',
|
|
2725
2837
|
};
|
|
2726
2838
|
const FINDING_OUTCOME_COLORS = {
|
|
2727
|
-
fixed: 'var(--success
|
|
2728
|
-
no_change_needed: 'var(--info
|
|
2839
|
+
fixed: 'var(--success)',
|
|
2840
|
+
no_change_needed: 'var(--info)',
|
|
2729
2841
|
skipped: 'var(--text-muted)',
|
|
2730
2842
|
};
|
|
2731
2843
|
function findingBadge(label, color) {
|
|
@@ -2818,7 +2930,7 @@ function renderToolParamsHtml(params) {
|
|
|
2818
2930
|
blocks.push({ k, display, full });
|
|
2819
2931
|
} else {
|
|
2820
2932
|
badges.push(
|
|
2821
|
-
`<span style="display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:3px;background:var(--bg-
|
|
2933
|
+
`<span style="display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:3px;background:var(--bg-hover);font-size:0.75rem"><span style="color:var(--text-muted)">${escapeHtml(k)}:</span> ${escapeHtml(display)}</span>`,
|
|
2822
2934
|
);
|
|
2823
2935
|
}
|
|
2824
2936
|
}
|
|
@@ -2833,20 +2945,20 @@ function renderToolParamsHtml(params) {
|
|
|
2833
2945
|
html += `<div style="margin-top:6px;font-size:0.75rem"><span style="color:var(--text-muted)">${escapeHtml(k)}:</span> <span style="word-break:break-all">${escapeHtml(display)}</span>${suffix}</div>`;
|
|
2834
2946
|
}
|
|
2835
2947
|
for (const { k, pretty } of jsonBlocks) {
|
|
2836
|
-
html += `<div
|
|
2837
|
-
<div
|
|
2838
|
-
<pre class="${TINTED_PRE_CLASS}" style="
|
|
2948
|
+
html += `<div class="tool-detail-section">
|
|
2949
|
+
<div class="tool-detail-label">${escapeHtml(k)}</div>
|
|
2950
|
+
<pre class="${TINTED_PRE_CLASS} tool-detail-pre-scroll" style="font-size:0.75rem">${escapeHtml(pretty)}</pre>
|
|
2839
2951
|
</div>`;
|
|
2840
2952
|
}
|
|
2841
2953
|
if (params.old_string || params.new_string) {
|
|
2842
|
-
html += `<div
|
|
2954
|
+
html += `<div class="tool-detail-section">`;
|
|
2843
2955
|
if (params.old_string) {
|
|
2844
|
-
html += `<div
|
|
2845
|
-
<pre class="${TINTED_PRE_CLASS}
|
|
2956
|
+
html += `<div class="tool-detail-label">old_string</div>
|
|
2957
|
+
<pre class="${TINTED_PRE_CLASS} msg-detail-pre-old">${escapeHtml(params.old_string)}</pre>`;
|
|
2846
2958
|
}
|
|
2847
2959
|
if (params.new_string) {
|
|
2848
|
-
html += `<div
|
|
2849
|
-
<pre class="${TINTED_PRE_CLASS}
|
|
2960
|
+
html += `<div class="tool-detail-label" style="margin-top:6px">new_string</div>
|
|
2961
|
+
<pre class="${TINTED_PRE_CLASS} msg-detail-pre-new">${escapeHtml(params.new_string)}</pre>`;
|
|
2850
2962
|
}
|
|
2851
2963
|
html += `</div>`;
|
|
2852
2964
|
}
|
|
@@ -2856,29 +2968,32 @@ function renderToolParamsHtml(params) {
|
|
|
2856
2968
|
// small writes render as before.
|
|
2857
2969
|
const fullContent = params.contentFull || params.content;
|
|
2858
2970
|
const isTruncated = !!params.contentFull || params.content.length > CONTENT_TRUNCATE_MAX;
|
|
2859
|
-
const
|
|
2860
|
-
|
|
2861
|
-
|
|
2971
|
+
const marker = '\n... (truncated)';
|
|
2972
|
+
let code = params.content.slice(0, CONTENT_TRUNCATE_MAX);
|
|
2973
|
+
if (code.endsWith(marker)) code = code.slice(0, -marker.length);
|
|
2974
|
+
const lang = langOfPath(params.file_path);
|
|
2975
|
+
const truncHtml = (highlightCode(code, lang) ?? escapeHtml(code)) + (isTruncated ? escapeHtml(marker) : '');
|
|
2862
2976
|
let writeMoreBtn = '',
|
|
2863
2977
|
fullBlock = '';
|
|
2864
2978
|
if (isTruncated) {
|
|
2865
|
-
const toggle = makeExpandToggle(
|
|
2979
|
+
const toggle = makeExpandToggle(truncHtml, escapeHtml(fullContent), {
|
|
2866
2980
|
fontSize: '0.75rem',
|
|
2867
2981
|
maxHeight: '500px',
|
|
2868
2982
|
tinted: true,
|
|
2983
|
+
highlightLang: lang,
|
|
2869
2984
|
});
|
|
2870
2985
|
writeMoreBtn = ` ${toggle.btn}`;
|
|
2871
2986
|
fullBlock = toggle.full;
|
|
2872
2987
|
}
|
|
2873
|
-
html += `<div
|
|
2874
|
-
<div
|
|
2875
|
-
<pre class="${TINTED_PRE_CLASS}
|
|
2988
|
+
html += `<div class="tool-detail-section">
|
|
2989
|
+
<div class="tool-detail-label">content${writeMoreBtn}</div>
|
|
2990
|
+
<pre class="${TINTED_PRE_CLASS} tool-detail-pre-scroll">${truncHtml}</pre>
|
|
2876
2991
|
${fullBlock}
|
|
2877
2992
|
</div>`;
|
|
2878
2993
|
}
|
|
2879
2994
|
if (params.plan) {
|
|
2880
|
-
html += `<div
|
|
2881
|
-
<div
|
|
2995
|
+
html += `<div class="tool-detail-section">
|
|
2996
|
+
<div class="tool-detail-label" style="margin-bottom:4px">Plan</div>
|
|
2882
2997
|
<div class="markdown-body">${renderMarkdown(params.plan)}</div>
|
|
2883
2998
|
</div>`;
|
|
2884
2999
|
}
|
|
@@ -2911,20 +3026,22 @@ function toolOutputHtml(text) {
|
|
|
2911
3026
|
return sanitizeOutputHtml(text);
|
|
2912
3027
|
}
|
|
2913
3028
|
|
|
3029
|
+
// Strings and comments share one pass, so the apostrophe in "# don't" does not open a string.
|
|
2914
3030
|
function highlightBash(escaped) {
|
|
2915
3031
|
return escaped
|
|
2916
|
-
.replace(
|
|
2917
|
-
|
|
3032
|
+
.replace(/('[\s\S]*?'|"[\s\S]*?")|^(\s*)(#.*)$/gm, (_, str, indent, comment) =>
|
|
3033
|
+
str ? `<span class="hljs-string">${str}</span>` : `${indent}<span class="hljs-comment">${comment}</span>`,
|
|
3034
|
+
)
|
|
2918
3035
|
.replace(
|
|
2919
3036
|
/\b(if|then|else|elif|fi|for|do|done|while|until|case|esac|function|return|in|select)\b/g,
|
|
2920
|
-
'<span
|
|
3037
|
+
'<span class="hljs-keyword">$1</span>',
|
|
2921
3038
|
)
|
|
2922
3039
|
.replace(
|
|
2923
3040
|
/\b(echo|cd|ls|cat|grep|awk|sed|rm|cp|mv|mkdir|chmod|chown|export|source|exit|test|read|printf|set|unset|eval|exec|trap|wait|kill|sudo|apt|npm|npx|git|docker|curl|wget|pip|python|node|make|dotnet)\b/g,
|
|
2924
|
-
'<span
|
|
3041
|
+
'<span class="hljs-built_in">$1</span>',
|
|
2925
3042
|
)
|
|
2926
|
-
.replace(/(\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*)/g, '<span
|
|
2927
|
-
.replace(/((?:^|\s)(?:&&|\|\||[|;])(?:\s|$))/g, '<span style="
|
|
3043
|
+
.replace(/(\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*)/g, '<span class="hljs-variable">$1</span>')
|
|
3044
|
+
.replace(/((?:^|\s)(?:&&|\|\||[|;])(?:\s|$))/g, '<span style="font-weight:bold">$1</span>');
|
|
2928
3045
|
}
|
|
2929
3046
|
|
|
2930
3047
|
const TINTED_PRE_CLASS = 'msg-detail-pre msg-detail-pre-tinted';
|
|
@@ -2932,6 +3049,12 @@ let _expandIdCounter = 0;
|
|
|
2932
3049
|
function _applyExpandToggle(btn, fullEl) {
|
|
2933
3050
|
const truncEl = btn.parentElement.nextElementSibling;
|
|
2934
3051
|
const expand = fullEl.style.display === 'none';
|
|
3052
|
+
// The full text is highlighted on first expand, so opening a message with a large Write stays fast.
|
|
3053
|
+
if (expand && fullEl.dataset.hlLang) {
|
|
3054
|
+
const html = highlightCode(fullEl.textContent, fullEl.dataset.hlLang);
|
|
3055
|
+
if (html) fullEl.innerHTML = html;
|
|
3056
|
+
delete fullEl.dataset.hlLang;
|
|
3057
|
+
}
|
|
2935
3058
|
fullEl.style.display = expand ? 'block' : 'none';
|
|
2936
3059
|
if (truncEl) truncEl.style.display = expand ? 'none' : 'block';
|
|
2937
3060
|
btn.textContent = expand ? 'Show less' : 'Show more';
|
|
@@ -2952,7 +3075,8 @@ function makeExpandToggle(_truncatedHtml, fullHtml, opts = {}) {
|
|
|
2952
3075
|
const cls = opts.tinted ? TINTED_PRE_CLASS : 'msg-detail-pre';
|
|
2953
3076
|
const btn = `<button data-expand-id="${id}" onclick="_toggleExpand(this)" class="expand-toggle-btn" style="font-size:${fontSize}">Show more</button>`;
|
|
2954
3077
|
const mhStyle = maxHeight ? `max-height:${maxHeight};` : '';
|
|
2955
|
-
const
|
|
3078
|
+
const hl = opts.highlightLang ? ` data-hl-lang="${escapeHtml(opts.highlightLang)}"` : '';
|
|
3079
|
+
const full = `<pre id="${id}" class="${cls}"${hl} style="${mhStyle}overflow:auto;display:none">${fullHtml}</pre>`;
|
|
2956
3080
|
return { btn, full };
|
|
2957
3081
|
}
|
|
2958
3082
|
|
|
@@ -2991,8 +3115,8 @@ function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
|
|
|
2991
3115
|
} else if (isTruncated) {
|
|
2992
3116
|
truncLabel = '<span style="color:var(--text-muted);font-size:0.8rem;margin-left:6px">(truncated)</span>';
|
|
2993
3117
|
}
|
|
2994
|
-
return `<div
|
|
2995
|
-
<div
|
|
3118
|
+
return `<div class="tool-detail-section-wide">
|
|
3119
|
+
<div class="tool-detail-heading">Output${truncLabel}</div>
|
|
2996
3120
|
<pre class="msg-detail-pre" style="overflow:auto">${escaped}</pre>
|
|
2997
3121
|
${fullBlock}
|
|
2998
3122
|
</div>`;
|
|
@@ -3018,9 +3142,7 @@ async function _toggleToolResultExpand(btn) {
|
|
|
3018
3142
|
btn.disabled = true;
|
|
3019
3143
|
btn.textContent = 'Loading…';
|
|
3020
3144
|
try {
|
|
3021
|
-
const r = await
|
|
3022
|
-
`/api/sessions/${encodeURIComponent(currentSessionId)}/tool-result/${encodeURIComponent(btn.dataset.toolUseId)}`,
|
|
3023
|
-
);
|
|
3145
|
+
const r = await api(apiPath`/api/sessions/${currentSessionId}/tool-result/${btn.dataset.toolUseId}`);
|
|
3024
3146
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
3025
3147
|
const { content } = await r.json();
|
|
3026
3148
|
f.innerHTML = toolOutputHtml(content);
|
|
@@ -3028,7 +3150,7 @@ async function _toggleToolResultExpand(btn) {
|
|
|
3028
3150
|
} catch (_e) {
|
|
3029
3151
|
btn.textContent = 'Show more';
|
|
3030
3152
|
btn.disabled = false;
|
|
3031
|
-
showToast('Failed to load full output');
|
|
3153
|
+
showToast('Failed to load full output', 'error');
|
|
3032
3154
|
return;
|
|
3033
3155
|
}
|
|
3034
3156
|
btn.disabled = false;
|
|
@@ -3061,14 +3183,10 @@ async function copyMsgToClipboard(btn) {
|
|
|
3061
3183
|
|
|
3062
3184
|
async function postAndToast(url, body, label) {
|
|
3063
3185
|
try {
|
|
3064
|
-
const r = await
|
|
3065
|
-
|
|
3066
|
-
headers: { 'Content-Type': 'application/json' },
|
|
3067
|
-
body: JSON.stringify(body),
|
|
3068
|
-
});
|
|
3069
|
-
showToast(r.ok ? `Opened ${label}` : `Failed to open ${label}`);
|
|
3186
|
+
const r = await api(url, { method: 'POST', body });
|
|
3187
|
+
showToast(`${r.ok ? 'Opened' : 'Failed to open'} ${label}`, r.ok ? 'success' : 'error');
|
|
3070
3188
|
} catch (_e) {
|
|
3071
|
-
showToast(`Failed to open ${label}
|
|
3189
|
+
showToast(`Failed to open ${label}`, 'error');
|
|
3072
3190
|
}
|
|
3073
3191
|
}
|
|
3074
3192
|
|
|
@@ -3281,7 +3399,7 @@ function togglePinFromAgentModal() {
|
|
|
3281
3399
|
async function dismissAgent(agentId) {
|
|
3282
3400
|
if (!currentSessionId || !agentId) return;
|
|
3283
3401
|
try {
|
|
3284
|
-
const res = await
|
|
3402
|
+
const res = await api(apiPath`/api/sessions/${currentSessionId}/agents/${agentId}/stop`, { method: 'POST' });
|
|
3285
3403
|
if (res.ok) {
|
|
3286
3404
|
currentWaiting = null;
|
|
3287
3405
|
fetchAgents(currentSessionId);
|
|
@@ -3367,14 +3485,6 @@ function showAgentModal(agentId) {
|
|
|
3367
3485
|
const dismissBtn = document.getElementById('agent-modal-dismiss-btn');
|
|
3368
3486
|
dismissBtn.style.display = agent.status === 'active' || agent.status === 'idle' ? '' : 'none';
|
|
3369
3487
|
modal.classList.add('visible');
|
|
3370
|
-
const keyHandler = (e) => {
|
|
3371
|
-
if (e.key === 'Escape') {
|
|
3372
|
-
e.preventDefault();
|
|
3373
|
-
closeAgentModal();
|
|
3374
|
-
document.removeEventListener('keydown', keyHandler);
|
|
3375
|
-
}
|
|
3376
|
-
};
|
|
3377
|
-
document.addEventListener('keydown', keyHandler);
|
|
3378
3488
|
}
|
|
3379
3489
|
|
|
3380
3490
|
function closeAgentModal() {
|
|
@@ -3406,15 +3516,8 @@ async function revealSession(id) {
|
|
|
3406
3516
|
let session = sessions.find((s) => s.id === id);
|
|
3407
3517
|
if (!session) {
|
|
3408
3518
|
lastSessionsHash = '';
|
|
3409
|
-
await fetchSessions();
|
|
3410
|
-
session =
|
|
3411
|
-
}
|
|
3412
|
-
if (!session) {
|
|
3413
|
-
// The id is outside the list the filters asked for. renderSession bails on a session
|
|
3414
|
-
// missing from `sessions`, so pull that one row in before opening it; later polls
|
|
3415
|
-
// keep it, because fetchSessions pins currentSessionId.
|
|
3416
|
-
session = await fetchSessionById(id);
|
|
3417
|
-
if (session) sessions.push(session);
|
|
3519
|
+
await fetchSessions(true, id);
|
|
3520
|
+
session = await ensureSessionLoaded(id);
|
|
3418
3521
|
}
|
|
3419
3522
|
const uncollapsed = session ? uncollapseFor(session) : false;
|
|
3420
3523
|
if (uncollapsed) persistCollapsedGroups();
|
|
@@ -3554,9 +3657,7 @@ function getFilteredSessions() {
|
|
|
3554
3657
|
|
|
3555
3658
|
function saveOrder(key, prev, next) {
|
|
3556
3659
|
if (next.length === prev.length && next.every((id, i) => id === prev[i])) return prev;
|
|
3557
|
-
|
|
3558
|
-
store.setItem(key, JSON.stringify(next));
|
|
3559
|
-
} catch (_) {}
|
|
3660
|
+
store.writeJson(key, next);
|
|
3560
3661
|
return next;
|
|
3561
3662
|
}
|
|
3562
3663
|
|
|
@@ -3603,36 +3704,8 @@ function renderSessions() {
|
|
|
3603
3704
|
const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
3604
3705
|
const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : applyStableSessionOrder(getFilteredSessions());
|
|
3605
3706
|
|
|
3606
|
-
|
|
3607
|
-
let emptyMsg = 'No sessions found';
|
|
3608
|
-
let emptyHint = 'Tasks appear when you use Claude Code';
|
|
3707
|
+
const outside = sidebarSearch.rows(zenMode ? '' : searchQuery, filteredSessions);
|
|
3609
3708
|
|
|
3610
|
-
if (zenMode) {
|
|
3611
|
-
emptyMsg = 'Zen mode: no session open';
|
|
3612
|
-
emptyHint = 'Press Ctrl+Shift+Z to leave zen mode, then open a session';
|
|
3613
|
-
} else if (searchQuery) {
|
|
3614
|
-
emptyMsg = `No results for "${searchQuery}"`;
|
|
3615
|
-
emptyHint = 'Try a different search term or clear the search';
|
|
3616
|
-
} else if (filterProject && sessionFilter === 'active') {
|
|
3617
|
-
emptyMsg = 'No active sessions for this project filter';
|
|
3618
|
-
emptyHint = 'Try "All Sessions" or clear the project filter';
|
|
3619
|
-
} else if (filterProject) {
|
|
3620
|
-
emptyMsg = 'No sessions for this project filter';
|
|
3621
|
-
emptyHint = 'Clear the project filter to see all';
|
|
3622
|
-
} else if (sessionFilter === 'active') {
|
|
3623
|
-
emptyMsg = 'No active sessions';
|
|
3624
|
-
emptyHint = 'Select "All Sessions" to see all';
|
|
3625
|
-
}
|
|
3626
|
-
sessionsList.innerHTML = `
|
|
3627
|
-
<div style="padding: 24px 12px; text-align: center; color: var(--text-muted); font-size: 12px;">
|
|
3628
|
-
<p>${emptyMsg}</p>
|
|
3629
|
-
<p style="margin-top: 8px; font-size: 11px;">${emptyHint}</p>
|
|
3630
|
-
</div>
|
|
3631
|
-
`;
|
|
3632
|
-
return;
|
|
3633
|
-
}
|
|
3634
|
-
|
|
3635
|
-
// Helper to render a single session card
|
|
3636
3709
|
const renderSessionCard = (session) => {
|
|
3637
3710
|
if (session.placeholder) return renderPlaceholderCard(session);
|
|
3638
3711
|
const total = session.taskCount;
|
|
@@ -3703,6 +3776,63 @@ function renderSessions() {
|
|
|
3703
3776
|
`;
|
|
3704
3777
|
};
|
|
3705
3778
|
|
|
3779
|
+
// `ungroup` marks the label as the drop target for pulling an item out of a named group.
|
|
3780
|
+
const sectionHtml = (key, text, ungroup, body, empty = false) => {
|
|
3781
|
+
const collapsed = collapsedProjectGroups.has(key);
|
|
3782
|
+
const escPath = escapeHtml(key);
|
|
3783
|
+
const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
|
|
3784
|
+
const emptyCls = empty ? ' sg-section-empty' : '';
|
|
3785
|
+
return `<div class="sg-section-label sg-section-toggle${ungroup ? ' sg-ungroup-zone' : ''}${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}" title="${escapeHtml(title)}">${groupChevronSvg(10)}<span>${text}</span></div>
|
|
3786
|
+
<div class="sg-section-body${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}">${body}</div>`;
|
|
3787
|
+
};
|
|
3788
|
+
|
|
3789
|
+
const countHtml = (arr) => {
|
|
3790
|
+
const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
3791
|
+
return `<span class="group-count" title="${active} active / ${arr.length} total">${active > 0 ? `<span class="group-count-active">${active}</span><span class="group-count-sep">/</span>` : ''}${arr.length}</span>`;
|
|
3792
|
+
};
|
|
3793
|
+
|
|
3794
|
+
const outsideHtml = outside.rows.length
|
|
3795
|
+
? sectionHtml(
|
|
3796
|
+
SECTION_OUTSIDE,
|
|
3797
|
+
`Outside current filters ${countHtml(outside.rows)}`,
|
|
3798
|
+
false,
|
|
3799
|
+
collapsedProjectGroups.has(SECTION_OUTSIDE) ? '' : outside.rows.map(renderSessionCard).join(''),
|
|
3800
|
+
)
|
|
3801
|
+
: '';
|
|
3802
|
+
|
|
3803
|
+
if (filteredSessions.length === 0) {
|
|
3804
|
+
let emptyMsg = 'No sessions found';
|
|
3805
|
+
let emptyHint = 'Tasks appear when you use Claude Code';
|
|
3806
|
+
|
|
3807
|
+
if (zenMode) {
|
|
3808
|
+
emptyMsg = 'Zen mode: no session open';
|
|
3809
|
+
emptyHint = 'Press Ctrl+Shift+Z to leave zen mode, then open a session';
|
|
3810
|
+
} else if (searchQuery) {
|
|
3811
|
+
emptyMsg = `No results for "${escapeHtml(searchQuery)}"`;
|
|
3812
|
+
emptyHint = outside.rows.length
|
|
3813
|
+
? 'Matches outside the current filters are below'
|
|
3814
|
+
: outside.pending
|
|
3815
|
+
? 'Searching all sessions…'
|
|
3816
|
+
: 'Try a different search term or clear the search';
|
|
3817
|
+
} else if (filterProject && sessionFilter === 'active') {
|
|
3818
|
+
emptyMsg = 'No active sessions for this project filter';
|
|
3819
|
+
emptyHint = 'Try "All Sessions" or clear the project filter';
|
|
3820
|
+
} else if (filterProject) {
|
|
3821
|
+
emptyMsg = 'No sessions for this project filter';
|
|
3822
|
+
emptyHint = 'Clear the project filter to see all';
|
|
3823
|
+
} else if (sessionFilter === 'active') {
|
|
3824
|
+
emptyMsg = 'No active sessions';
|
|
3825
|
+
emptyHint = 'Select "All Sessions" to see all';
|
|
3826
|
+
}
|
|
3827
|
+
sessionsList.innerHTML = `
|
|
3828
|
+
<div style="padding: 24px 12px; text-align: center; color: var(--text-muted); font-size: 12px;">
|
|
3829
|
+
<p>${emptyMsg}</p>
|
|
3830
|
+
<p style="margin-top: 8px; font-size: 11px;">${emptyHint}</p>
|
|
3831
|
+
</div>
|
|
3832
|
+
${outsideHtml}`;
|
|
3833
|
+
return;
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3706
3836
|
// Zen draws one card and its panel, so it returns before the grouping below — leaving it in
|
|
3707
3837
|
// that chain would build the whole list only to discard it.
|
|
3708
3838
|
if (zenMode) {
|
|
@@ -3722,23 +3852,9 @@ function renderSessions() {
|
|
|
3722
3852
|
return;
|
|
3723
3853
|
}
|
|
3724
3854
|
|
|
3725
|
-
const groupPinned = store.getItem(
|
|
3855
|
+
const groupPinned = store.getItem(GROUP_PINNED_KEY) !== 'false';
|
|
3726
3856
|
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
|
|
3727
3857
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
3728
|
-
const countHtml = (arr) => {
|
|
3729
|
-
const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
3730
|
-
return `<span class="group-count" title="${active} active / ${arr.length} total">${active > 0 ? `<span class="group-count-active">${active}</span><span class="group-count-sep">/</span>` : ''}${arr.length}</span>`;
|
|
3731
|
-
};
|
|
3732
|
-
// `ungroup` marks the label as the drop target for pulling an item out of a named group.
|
|
3733
|
-
const sectionHtml = (key, text, ungroup, body, empty = false) => {
|
|
3734
|
-
const collapsed = collapsedProjectGroups.has(key);
|
|
3735
|
-
const escPath = escapeHtml(key);
|
|
3736
|
-
const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
|
|
3737
|
-
const emptyCls = empty ? ' sg-section-empty' : '';
|
|
3738
|
-
return `<div class="sg-section-label sg-section-toggle${ungroup ? ' sg-ungroup-zone' : ''}${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}" title="${escapeHtml(title)}">${groupChevronSvg(10)}<span>${text}</span></div>
|
|
3739
|
-
<div class="sg-section-body${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}">${body}</div>`;
|
|
3740
|
-
};
|
|
3741
|
-
|
|
3742
3858
|
const renderGroupSessions = (sessions, pinKey) => {
|
|
3743
3859
|
if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
|
|
3744
3860
|
const gIdlePinned = sessions.filter(isInPinnedGroup);
|
|
@@ -3958,13 +4074,13 @@ function renderSessions() {
|
|
|
3958
4074
|
const noProjects = sortedGroups.length === 0 && ungrouped.length === 0;
|
|
3959
4075
|
html += sectioned ? sectionHtml(SECTION_PROJECTS, 'Projects', true, projectsHtml, noProjects) : projectsHtml;
|
|
3960
4076
|
|
|
3961
|
-
sessionsList.innerHTML = html;
|
|
4077
|
+
sessionsList.innerHTML = html + outsideHtml;
|
|
3962
4078
|
} else {
|
|
3963
4079
|
const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
|
|
3964
4080
|
const restHtml =
|
|
3965
4081
|
sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
|
|
3966
4082
|
const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
|
|
3967
|
-
sessionsList.innerHTML = html + sgSectionHtml(true) + tail;
|
|
4083
|
+
sessionsList.innerHTML = html + sgSectionHtml(true) + tail + outsideHtml;
|
|
3968
4084
|
}
|
|
3969
4085
|
|
|
3970
4086
|
const navItems = getNavigableItems();
|
|
@@ -4283,11 +4399,7 @@ function startAddTask(tile) {
|
|
|
4283
4399
|
input.disabled = true;
|
|
4284
4400
|
const sessionId = currentSessionId;
|
|
4285
4401
|
try {
|
|
4286
|
-
const res = await
|
|
4287
|
-
method: 'POST',
|
|
4288
|
-
headers: { 'Content-Type': 'application/json' },
|
|
4289
|
-
body: JSON.stringify({ subject }),
|
|
4290
|
-
});
|
|
4402
|
+
const res = await api(apiPath`/api/tasks/${sessionId}`, { method: 'POST', body: { subject } });
|
|
4291
4403
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
4292
4404
|
// The response carries the finished task, and the watcher will resend it within the
|
|
4293
4405
|
// SSE debounce anyway -- so show it now rather than paying a session refetch for it.
|
|
@@ -4379,11 +4491,7 @@ async function onColumnDrop(e) {
|
|
|
4379
4491
|
task.status = newStatus;
|
|
4380
4492
|
renderKanban();
|
|
4381
4493
|
try {
|
|
4382
|
-
const res = await
|
|
4383
|
-
method: 'PUT',
|
|
4384
|
-
headers: { 'Content-Type': 'application/json' },
|
|
4385
|
-
body: JSON.stringify({ status: newStatus }),
|
|
4386
|
-
});
|
|
4494
|
+
const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'PUT', body: { status: newStatus } });
|
|
4387
4495
|
if (res.ok) return;
|
|
4388
4496
|
} catch (_) {}
|
|
4389
4497
|
if (task.status === newStatus) {
|
|
@@ -4420,34 +4528,28 @@ function pinKey(projectPath) {
|
|
|
4420
4528
|
}
|
|
4421
4529
|
|
|
4422
4530
|
function loadSessionGroups() {
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
} catch (_) {
|
|
4442
|
-
sessionGroups = [];
|
|
4443
|
-
sgReleased = new Set();
|
|
4444
|
-
}
|
|
4531
|
+
const saved = store.readJson(SESSION_GROUPS_KEY);
|
|
4532
|
+
const list = saved?.groups;
|
|
4533
|
+
sessionGroups = (Array.isArray(list) ? list : [])
|
|
4534
|
+
.filter((g) => g && typeof g.id === 'string')
|
|
4535
|
+
.map((g) => ({
|
|
4536
|
+
id: g.id,
|
|
4537
|
+
name: typeof g.name === 'string' && g.name.trim() ? g.name : 'Group',
|
|
4538
|
+
members: (Array.isArray(g.members) ? g.members : [])
|
|
4539
|
+
.filter((m) => m && (m.type === 'project' || m.type === 'session') && typeof m.ref === 'string')
|
|
4540
|
+
.map((m) => {
|
|
4541
|
+
const out = { type: m.type, ref: m.ref };
|
|
4542
|
+
if (typeof m.under === 'string' && m.under) out.under = m.under;
|
|
4543
|
+
if (m.loose === true) out.loose = true;
|
|
4544
|
+
return out;
|
|
4545
|
+
}),
|
|
4546
|
+
}));
|
|
4547
|
+
const released = saved?.released;
|
|
4548
|
+
sgReleased = new Set(Array.isArray(released) ? released.filter((id) => typeof id === 'string') : []);
|
|
4445
4549
|
}
|
|
4446
4550
|
|
|
4447
4551
|
function persistSessionGroups() {
|
|
4448
|
-
|
|
4449
|
-
store.setItem(SESSION_GROUPS_KEY, JSON.stringify({ version: 1, groups: sessionGroups, released: [...sgReleased] }));
|
|
4450
|
-
} catch (_) {}
|
|
4552
|
+
store.writeJson(SESSION_GROUPS_KEY, { version: 1, groups: sessionGroups, released: [...sgReleased] });
|
|
4451
4553
|
}
|
|
4452
4554
|
|
|
4453
4555
|
function sgGroupById(id) {
|
|
@@ -4962,10 +5064,13 @@ function initSessionGroupsDnd() {
|
|
|
4962
5064
|
// Non-drag path: right-click or long-press a session / project header.
|
|
4963
5065
|
let sgLongPressTimer = null;
|
|
4964
5066
|
|
|
4965
|
-
function sgCloseMenu() {
|
|
5067
|
+
function sgCloseMenu(restore) {
|
|
4966
5068
|
document.removeEventListener('keydown', sgMenuKeydown, true);
|
|
4967
5069
|
const menu = document.getElementById('sg-menu');
|
|
4968
|
-
if (menu)
|
|
5070
|
+
if (menu) {
|
|
5071
|
+
menu.remove();
|
|
5072
|
+
takeMenuFocus(restore);
|
|
5073
|
+
}
|
|
4969
5074
|
}
|
|
4970
5075
|
|
|
4971
5076
|
// Run in the capture phase: the global handler would otherwise read these keys as sidebar shortcuts.
|
|
@@ -4979,11 +5084,25 @@ function menuKeydown(e, items, close) {
|
|
|
4979
5084
|
else if (matchKey(e, 'ArrowUp')) next = i < 0 ? items.length - 1 : (i - 1 + items.length) % items.length;
|
|
4980
5085
|
else if (e.key === 'Home') next = 0;
|
|
4981
5086
|
else if (e.key === 'End') next = items.length - 1;
|
|
4982
|
-
else if (e.key === 'Escape' || e.key === 'Tab') close();
|
|
5087
|
+
else if (e.key === 'Escape' || e.key === 'Tab') close(true);
|
|
4983
5088
|
if (next !== null) items[next]?.focus();
|
|
4984
5089
|
}
|
|
4985
5090
|
|
|
5091
|
+
let menuReturnFocus = null;
|
|
5092
|
+
|
|
5093
|
+
// Only when nothing else has taken focus: it is on body, or still inside what is closing.
|
|
5094
|
+
function restoreFocusTo(el, closing) {
|
|
5095
|
+
const now = document.activeElement;
|
|
5096
|
+
if (el?.isConnected && (!now || now === document.body || closing?.contains(now))) el.focus();
|
|
5097
|
+
}
|
|
5098
|
+
|
|
5099
|
+
function takeMenuFocus(restore) {
|
|
5100
|
+
if (restore) restoreFocusTo(menuReturnFocus);
|
|
5101
|
+
menuReturnFocus = null;
|
|
5102
|
+
}
|
|
5103
|
+
|
|
4986
5104
|
function placeMenu(menu, x, y) {
|
|
5105
|
+
menuReturnFocus = document.activeElement;
|
|
4987
5106
|
document.body.appendChild(menu);
|
|
4988
5107
|
const rect = menu.getBoundingClientRect();
|
|
4989
5108
|
menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`;
|
|
@@ -5022,10 +5141,12 @@ function sgOpenMenu(x, y, kind, ref) {
|
|
|
5022
5141
|
menu.id = 'sg-menu';
|
|
5023
5142
|
menu.className = 'sg-menu';
|
|
5024
5143
|
menu.setAttribute('role', 'menu');
|
|
5025
|
-
|
|
5144
|
+
const canFork = kind === 'session' && !!forkableSession(ref);
|
|
5145
|
+
menu.setAttribute('aria-label', canFork ? 'Session actions' : `Move ${label} to group`);
|
|
5026
5146
|
menu.dataset.sgKind = kind;
|
|
5027
5147
|
menu.dataset.sgRef = ref;
|
|
5028
5148
|
menu.innerHTML = `
|
|
5149
|
+
${canFork ? '<div class="sg-menu-label" aria-hidden="true">Session</div><button class="sg-menu-item" role="menuitem" data-sg-action="fork" title="claude --resume --fork-session">Fork</button>' : ''}
|
|
5029
5150
|
<div class="sg-menu-label" aria-hidden="true">Move ${label} to group</div>
|
|
5030
5151
|
${rows}
|
|
5031
5152
|
<button class="sg-menu-item" role="menuitem" data-sg-move="__new__">+ New group…</button>
|
|
@@ -5065,7 +5186,7 @@ function sgOnTouchStart(e) {
|
|
|
5065
5186
|
document.addEventListener('click', (e) => {
|
|
5066
5187
|
const item = e.target.closest('.sg-menu-item');
|
|
5067
5188
|
if (!item) {
|
|
5068
|
-
if (!e.target.closest('.sg-menu')) sgCloseMenu();
|
|
5189
|
+
if (!e.target.closest('.sg-menu')) sgCloseMenu(true);
|
|
5069
5190
|
return;
|
|
5070
5191
|
}
|
|
5071
5192
|
e.stopPropagation();
|
|
@@ -5073,6 +5194,7 @@ document.addEventListener('click', (e) => {
|
|
|
5073
5194
|
const { sgKind, sgRef } = menu.dataset;
|
|
5074
5195
|
const move = item.dataset.sgMove;
|
|
5075
5196
|
sgCloseMenu();
|
|
5197
|
+
if (item.dataset.sgAction === 'fork') return forkSession(sgRef);
|
|
5076
5198
|
if (move === '__none__') {
|
|
5077
5199
|
sgDetach(sgKind, sgRef);
|
|
5078
5200
|
persistSessionGroups();
|
|
@@ -5288,9 +5410,7 @@ function setGroupCollapsed(header, collapsed) {
|
|
|
5288
5410
|
}
|
|
5289
5411
|
|
|
5290
5412
|
function persistCollapsedGroups() {
|
|
5291
|
-
|
|
5292
|
-
store.setItem('collapsedGroups', JSON.stringify([...collapsedProjectGroups]));
|
|
5293
|
-
} catch (_) {}
|
|
5413
|
+
store.writeJson(COLLAPSED_GROUPS_KEY, [...collapsedProjectGroups]);
|
|
5294
5414
|
}
|
|
5295
5415
|
|
|
5296
5416
|
let prevActiveSessionIds = null; // null until primed by the first onlyNew pass
|
|
@@ -5316,18 +5436,16 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
5316
5436
|
}
|
|
5317
5437
|
|
|
5318
5438
|
// Drops every collapse key that could hide this session: its project, a named group wrapping
|
|
5319
|
-
// the project or the session itself, the pinned sub-section only when the card renders inside
|
|
5320
|
-
// it (pinned, not sticky, idle — see renderGroupSessions), and
|
|
5321
|
-
//
|
|
5439
|
+
// the project or the session itself and the project block it renders under there, the pinned sub-section only when the card renders inside
|
|
5440
|
+
// it (pinned, not sticky, idle — see renderGroupSessions), and the section header that holds it:
|
|
5441
|
+
// Groups for a grouped session, else Projects or Sessions, which depends on the view. Returns
|
|
5442
|
+
// whether anything changed.
|
|
5322
5443
|
function uncollapseFor(session) {
|
|
5323
5444
|
const group = sgGroupForSession(session);
|
|
5324
5445
|
const keys = [
|
|
5325
5446
|
sessionProjectKey(session) || '__ungrouped__',
|
|
5326
5447
|
...pinnedCollapseKeys(session),
|
|
5327
|
-
group
|
|
5328
|
-
SECTION_GROUPS,
|
|
5329
|
-
SECTION_PROJECTS,
|
|
5330
|
-
SECTION_SESSIONS,
|
|
5448
|
+
...(group ? [sgKey(group.id), sgHostOf(group, session), SECTION_GROUPS] : [SECTION_PROJECTS, SECTION_SESSIONS]),
|
|
5331
5449
|
];
|
|
5332
5450
|
let changed = false;
|
|
5333
5451
|
for (const key of keys) {
|
|
@@ -5479,42 +5597,6 @@ sessionsList.addEventListener('click', (e) => {
|
|
|
5479
5597
|
if (focusZone === 'sidebar' || (!taskInUse && !terminalPaneFocused())) setFocusZone('sidebar', el);
|
|
5480
5598
|
});
|
|
5481
5599
|
|
|
5482
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5483
|
-
function getAvailableTasksOptions(currentTaskId = null) {
|
|
5484
|
-
const pending = currentTasks.filter((t) => t.status === 'pending' && t.id !== currentTaskId);
|
|
5485
|
-
const inProgress = currentTasks.filter((t) => t.status === 'in_progress' && t.id !== currentTaskId);
|
|
5486
|
-
const completed = currentTasks.filter((t) => t.status === 'completed' && t.id !== currentTaskId);
|
|
5487
|
-
|
|
5488
|
-
// Build options grouped by status
|
|
5489
|
-
let options = '';
|
|
5490
|
-
|
|
5491
|
-
if (pending.length > 0) {
|
|
5492
|
-
options += '<optgroup label="Pending">';
|
|
5493
|
-
pending.forEach((t, _idx) => {
|
|
5494
|
-
options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
|
|
5495
|
-
});
|
|
5496
|
-
options += '</optgroup>';
|
|
5497
|
-
}
|
|
5498
|
-
|
|
5499
|
-
if (inProgress.length > 0) {
|
|
5500
|
-
options += '<optgroup label="In Progress">';
|
|
5501
|
-
inProgress.forEach((t, _idx) => {
|
|
5502
|
-
options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
|
|
5503
|
-
});
|
|
5504
|
-
options += '</optgroup>';
|
|
5505
|
-
}
|
|
5506
|
-
|
|
5507
|
-
if (completed.length > 0) {
|
|
5508
|
-
options += '<optgroup label="Completed">';
|
|
5509
|
-
completed.forEach((t, _idx) => {
|
|
5510
|
-
options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
|
|
5511
|
-
});
|
|
5512
|
-
options += '</optgroup>';
|
|
5513
|
-
}
|
|
5514
|
-
|
|
5515
|
-
return options;
|
|
5516
|
-
}
|
|
5517
|
-
|
|
5518
5600
|
//#endregion
|
|
5519
5601
|
|
|
5520
5602
|
//#region TASK_DETAIL
|
|
@@ -5525,15 +5607,9 @@ async function showTaskDetail(taskId, sessionId = null) {
|
|
|
5525
5607
|
|
|
5526
5608
|
// If task not found in currentTasks, fetch it from the session
|
|
5527
5609
|
if (!task && sessionId && sessionId !== 'undefined') {
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
task = tasks.find((t) => t.id === taskId);
|
|
5532
|
-
if (!task) return;
|
|
5533
|
-
} catch (error) {
|
|
5534
|
-
console.error('Failed to fetch task:', error);
|
|
5535
|
-
return;
|
|
5536
|
-
}
|
|
5610
|
+
const tasks = await getJson(apiPath`/api/sessions/${sessionId}`, []);
|
|
5611
|
+
task = tasks.find((t) => t.id === taskId);
|
|
5612
|
+
if (!task) return;
|
|
5537
5613
|
}
|
|
5538
5614
|
|
|
5539
5615
|
if (!task) return;
|
|
@@ -5713,11 +5789,7 @@ function editDescription(descEl, task, sessionId) {
|
|
|
5713
5789
|
|
|
5714
5790
|
async function saveTaskField(taskId, sessionId, field, value) {
|
|
5715
5791
|
try {
|
|
5716
|
-
const res = await
|
|
5717
|
-
method: 'PUT',
|
|
5718
|
-
headers: { 'Content-Type': 'application/json' },
|
|
5719
|
-
body: JSON.stringify({ [field]: value }),
|
|
5720
|
-
});
|
|
5792
|
+
const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'PUT', body: { [field]: value } });
|
|
5721
5793
|
|
|
5722
5794
|
if (res.ok) {
|
|
5723
5795
|
lastCurrentTasksHash = null;
|
|
@@ -5736,47 +5808,6 @@ function closeDetailPanel() {
|
|
|
5736
5808
|
taskHighlightDimmed = false;
|
|
5737
5809
|
}
|
|
5738
5810
|
|
|
5739
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5740
|
-
function showBlockedTaskModal(task) {
|
|
5741
|
-
const messageDiv = document.getElementById('blocked-task-message');
|
|
5742
|
-
|
|
5743
|
-
const blockedByList = task.blockedBy
|
|
5744
|
-
.map((id) => {
|
|
5745
|
-
const blockingTask = currentTasks.find((t) => t.id === id);
|
|
5746
|
-
if (blockingTask) {
|
|
5747
|
-
return `<li><strong>#${blockingTask.id}</strong> - ${escapeHtml(blockingTask.subject)}</li>`;
|
|
5748
|
-
}
|
|
5749
|
-
return `<li><strong>#${id}</strong></li>`;
|
|
5750
|
-
})
|
|
5751
|
-
.join('');
|
|
5752
|
-
|
|
5753
|
-
messageDiv.innerHTML = `
|
|
5754
|
-
<p style="margin-bottom: 12px;">Task <strong>#${task.id}</strong> - ${escapeHtml(task.subject)} is currently blocked by:</p>
|
|
5755
|
-
<ul style="margin: 0 0 16px 20px; padding: 0;">${blockedByList}</ul>
|
|
5756
|
-
<p style="margin: 0; color: var(--text-secondary); font-size: 13px;">
|
|
5757
|
-
Please resolve these dependencies before moving this task to <strong>In Progress</strong>.
|
|
5758
|
-
</p>
|
|
5759
|
-
`;
|
|
5760
|
-
|
|
5761
|
-
const modal = document.getElementById('blocked-task-modal');
|
|
5762
|
-
modal.classList.add('visible');
|
|
5763
|
-
|
|
5764
|
-
// Handle ESC key
|
|
5765
|
-
const keyHandler = (e) => {
|
|
5766
|
-
if (e.key === 'Escape') {
|
|
5767
|
-
e.preventDefault();
|
|
5768
|
-
closeBlockedTaskModal();
|
|
5769
|
-
document.removeEventListener('keydown', keyHandler);
|
|
5770
|
-
}
|
|
5771
|
-
};
|
|
5772
|
-
document.addEventListener('keydown', keyHandler);
|
|
5773
|
-
}
|
|
5774
|
-
|
|
5775
|
-
function closeBlockedTaskModal() {
|
|
5776
|
-
const modal = document.getElementById('blocked-task-modal');
|
|
5777
|
-
modal.classList.remove('visible');
|
|
5778
|
-
}
|
|
5779
|
-
|
|
5780
5811
|
//#endregion
|
|
5781
5812
|
|
|
5782
5813
|
//#region DELETE_TASK
|
|
@@ -5789,6 +5820,7 @@ function confirmModal({ title, message, okLabel }) {
|
|
|
5789
5820
|
document.getElementById('confirm-modal-message').textContent = message;
|
|
5790
5821
|
const buttons = [document.getElementById('confirm-cancel-btn'), document.getElementById('confirm-ok-btn')];
|
|
5791
5822
|
buttons[1].textContent = okLabel;
|
|
5823
|
+
const returnFocus = document.activeElement;
|
|
5792
5824
|
modal.classList.add('visible');
|
|
5793
5825
|
buttons[1].focus();
|
|
5794
5826
|
|
|
@@ -5807,6 +5839,7 @@ function confirmModal({ title, message, okLabel }) {
|
|
|
5807
5839
|
modal.classList.remove('visible');
|
|
5808
5840
|
document.removeEventListener('keydown', onKey);
|
|
5809
5841
|
closeConfirmModal = null;
|
|
5842
|
+
if (!ok) restoreFocusTo(returnFocus, modal);
|
|
5810
5843
|
resolve(ok);
|
|
5811
5844
|
};
|
|
5812
5845
|
});
|
|
@@ -5823,20 +5856,18 @@ async function deleteTask(taskId, sessionId) {
|
|
|
5823
5856
|
if (!ok) return;
|
|
5824
5857
|
|
|
5825
5858
|
try {
|
|
5826
|
-
const res = await
|
|
5827
|
-
method: 'DELETE',
|
|
5828
|
-
});
|
|
5859
|
+
const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'DELETE' });
|
|
5829
5860
|
|
|
5830
5861
|
if (res.ok) {
|
|
5831
5862
|
closeDetailPanel();
|
|
5832
5863
|
await refreshCurrentView();
|
|
5833
5864
|
} else {
|
|
5834
|
-
const error = await res.json();
|
|
5835
|
-
|
|
5865
|
+
const error = await res.json().catch(() => ({}));
|
|
5866
|
+
showToast(`Failed to delete task: ${error.error || 'Unknown error'}`, 'error');
|
|
5836
5867
|
}
|
|
5837
5868
|
} catch (error) {
|
|
5838
5869
|
console.error('Failed to delete task:', error);
|
|
5839
|
-
|
|
5870
|
+
showToast('Failed to delete task', 'error');
|
|
5840
5871
|
}
|
|
5841
5872
|
}
|
|
5842
5873
|
|
|
@@ -5873,14 +5904,20 @@ const SHORTCUT_TABS = [
|
|
|
5873
5904
|
],
|
|
5874
5905
|
},
|
|
5875
5906
|
{
|
|
5876
|
-
title: '
|
|
5907
|
+
title: 'Tabs',
|
|
5877
5908
|
rows: [
|
|
5878
|
-
{ keys: ['\\'], label: 'Toggle sidebar' },
|
|
5879
5909
|
{ keys: ['[', ']'], label: 'Previous / next tab (a split counts as one)' },
|
|
5880
5910
|
{ keys: ['Alt', '[ / ]'], combo: true, label: 'Change the tab in the focused side of a split' },
|
|
5881
5911
|
{ keys: ['|'], label: 'Split with the tab before it / unsplit (or drag a tab onto a side)' },
|
|
5882
5912
|
{ keys: ['{', '}'], label: 'Focus left / right side of a split' },
|
|
5883
|
-
{ keys: ['Alt', 'W'], combo: true, label: 'Close the focused
|
|
5913
|
+
{ keys: ['Alt', 'W'], combo: true, label: 'Close the focused pane' },
|
|
5914
|
+
{ keys: ['Shift', 'F10'], combo: true, label: 'Focused tab menu (or Menu key)' },
|
|
5915
|
+
],
|
|
5916
|
+
},
|
|
5917
|
+
{
|
|
5918
|
+
title: 'View',
|
|
5919
|
+
rows: [
|
|
5920
|
+
{ keys: ['\\'], label: 'Toggle sidebar' },
|
|
5884
5921
|
{ keys: ['T'], label: 'Toggle theme' },
|
|
5885
5922
|
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
5886
5923
|
{ keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
@@ -5890,13 +5927,6 @@ const SHORTCUT_TABS = [
|
|
|
5890
5927
|
{ keys: ['?'], label: 'Show this help' },
|
|
5891
5928
|
],
|
|
5892
5929
|
},
|
|
5893
|
-
{
|
|
5894
|
-
title: 'Copy',
|
|
5895
|
-
rows: [
|
|
5896
|
-
{ keys: ['Shift', 'C'], combo: true, label: 'Session id' },
|
|
5897
|
-
{ keys: ['Ctrl', 'Shift', 'C'], combo: true, label: 'Session name (SendMessage address)' },
|
|
5898
|
-
],
|
|
5899
|
-
},
|
|
5900
5930
|
],
|
|
5901
5931
|
},
|
|
5902
5932
|
{
|
|
@@ -5911,7 +5941,7 @@ const SHORTCUT_TABS = [
|
|
|
5911
5941
|
{ keys: ['I'], label: 'Session info' },
|
|
5912
5942
|
{ keys: ['.'], label: 'Pin / unpin' },
|
|
5913
5943
|
{ keys: ['>'], label: 'Toggle sticky' },
|
|
5914
|
-
{ keys: ['Shift', 'F10'], combo: true, label: '
|
|
5944
|
+
{ keys: ['Shift', 'F10'], combo: true, label: 'Session menu: move to group, fork (or Menu key)' },
|
|
5915
5945
|
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
|
|
5916
5946
|
{ keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
5917
5947
|
{ keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
|
|
@@ -5925,6 +5955,8 @@ const SHORTCUT_TABS = [
|
|
|
5925
5955
|
{ keys: ['Ctrl', 'Alt', 'N'], combo: true, hubMod: true, label: 'New session' },
|
|
5926
5956
|
{ keys: ['Ctrl', 'Alt', 'R'], combo: true, hubMod: true, label: 'Resume session (claude -r)' },
|
|
5927
5957
|
{ keys: ['Ctrl', 'Alt', 'S'], combo: true, hubMod: true, label: 'Swap to previous session' },
|
|
5958
|
+
{ keys: ['Shift', 'C'], combo: true, label: 'Copy session id' },
|
|
5959
|
+
{ keys: ['Ctrl', 'Shift', 'C'], combo: true, label: 'Copy session name (SendMessage address)' },
|
|
5928
5960
|
],
|
|
5929
5961
|
},
|
|
5930
5962
|
],
|
|
@@ -5937,7 +5969,7 @@ const SHORTCUT_TABS = [
|
|
|
5937
5969
|
rows: [
|
|
5938
5970
|
{ keys: ['Ctrl', '`'], combo: true, label: 'Show / hide terminal' },
|
|
5939
5971
|
{ keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
|
|
5940
|
-
{ keys: ['Alt', 'Shift', '`'], combo: true, label: 'End
|
|
5972
|
+
{ keys: ['Alt', 'Shift', '`'], combo: true, label: 'End terminal' },
|
|
5941
5973
|
{ keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
|
|
5942
5974
|
{ keys: ['Ctrl', '+/−/0'], combo: true, label: 'Terminal text size' },
|
|
5943
5975
|
],
|
|
@@ -5951,9 +5983,9 @@ const SHORTCUT_TABS = [
|
|
|
5951
5983
|
{
|
|
5952
5984
|
title: 'Hub',
|
|
5953
5985
|
rows: [
|
|
5954
|
-
{ keys: ['M'], label: 'Jump to marketplace' },
|
|
5955
|
-
{ keys: ['$'], label: 'Jump to cost' },
|
|
5956
|
-
{ keys: ['Ctrl', 'M'], combo: true, label: 'Jump to memory' },
|
|
5986
|
+
{ keys: ['M'], opens: 'project.plugins', label: 'Jump to marketplace' },
|
|
5987
|
+
{ keys: ['$'], opens: 'session.cost', label: 'Jump to cost' },
|
|
5988
|
+
{ keys: ['Ctrl', 'M'], opens: 'project.memory', combo: true, label: 'Jump to memory' },
|
|
5957
5989
|
{
|
|
5958
5990
|
keys: ['Ctrl', 'Alt', '←/→'],
|
|
5959
5991
|
action: ['hub.prevApp', 'hub.nextApp'],
|
|
@@ -6035,8 +6067,14 @@ function buildHelpShortcuts(groups) {
|
|
|
6035
6067
|
let helpTab = 0;
|
|
6036
6068
|
const helpFilter = document.getElementById('help-filter');
|
|
6037
6069
|
|
|
6038
|
-
// Hub keys do nothing outside Claude Code Hub,
|
|
6039
|
-
const
|
|
6070
|
+
// Hub keys do nothing outside Claude Code Hub, except the app links that the --*-url flags set.
|
|
6071
|
+
const hubRowWorks = (r) => (r.opens ? hub.can(r.opens) : hub.inHub);
|
|
6072
|
+
const helpTabs = () =>
|
|
6073
|
+
SHORTCUT_TABS.map((t) =>
|
|
6074
|
+
t.hub
|
|
6075
|
+
? { ...t, groups: t.groups.map((g) => ({ ...g, rows: g.rows.filter(hubRowWorks) })).filter((g) => g.rows.length) }
|
|
6076
|
+
: t,
|
|
6077
|
+
).filter((t) => t.groups.length);
|
|
6040
6078
|
|
|
6041
6079
|
// The matching rows of every tab, in their groups.
|
|
6042
6080
|
function filteredShortcutGroups(query) {
|
|
@@ -6044,7 +6082,7 @@ function filteredShortcutGroups(query) {
|
|
|
6044
6082
|
.flatMap((t) => t.groups)
|
|
6045
6083
|
.map((g) => ({
|
|
6046
6084
|
title: g.title,
|
|
6047
|
-
rows: g.rows.filter((r) => fuzzyMatch(`${r.label} ${helpKeys(r).join(' ')}
|
|
6085
|
+
rows: g.rows.filter((r) => fuzzyMatch(`${r.label} ${helpKeys(r).join(' ')}`.replaceAll('−', '-'), query)),
|
|
6048
6086
|
}))
|
|
6049
6087
|
.filter((g) => g.rows.length);
|
|
6050
6088
|
}
|
|
@@ -6425,7 +6463,7 @@ function _updateStorageTotal() {
|
|
|
6425
6463
|
let _storageKnownSessions = null;
|
|
6426
6464
|
|
|
6427
6465
|
async function _fetchKnownSessions() {
|
|
6428
|
-
const res = await
|
|
6466
|
+
const res = await api('/api/sessions/known');
|
|
6429
6467
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
6430
6468
|
const known = new Map((await res.json()).map((s) => [s.id, s]));
|
|
6431
6469
|
for (const s of sessions) known.set(s.id, s);
|
|
@@ -6518,12 +6556,10 @@ function _renderStorageSessions() {
|
|
|
6518
6556
|
|
|
6519
6557
|
const msgMap = new Map();
|
|
6520
6558
|
for (const key of store.keys()) {
|
|
6521
|
-
if (!key.startsWith(
|
|
6522
|
-
const sid = key.slice(
|
|
6523
|
-
|
|
6524
|
-
|
|
6525
|
-
if (pins.length) msgMap.set(sid, { pins, key });
|
|
6526
|
-
} catch {}
|
|
6559
|
+
if (!key.startsWith(PINNED_MESSAGES_PREFIX)) continue;
|
|
6560
|
+
const sid = key.slice(PINNED_MESSAGES_PREFIX.length);
|
|
6561
|
+
const pins = loadPins(sid);
|
|
6562
|
+
if (pins.length) msgMap.set(sid, { pins });
|
|
6527
6563
|
}
|
|
6528
6564
|
|
|
6529
6565
|
const allIds = [...new Set([...pinnedIds, ...msgMap.keys()])];
|
|
@@ -6603,7 +6639,7 @@ function _storageUnpinSession(id) {
|
|
|
6603
6639
|
}
|
|
6604
6640
|
|
|
6605
6641
|
function _storageClearSessionPins(sessionId) {
|
|
6606
|
-
store.removeItem(
|
|
6642
|
+
store.removeItem(pinsKey(sessionId));
|
|
6607
6643
|
if (currentSessionId === sessionId) {
|
|
6608
6644
|
currentPins = [];
|
|
6609
6645
|
const el = document.getElementById('message-panel-pinned');
|
|
@@ -6614,20 +6650,16 @@ function _storageClearSessionPins(sessionId) {
|
|
|
6614
6650
|
}
|
|
6615
6651
|
|
|
6616
6652
|
function _storageUnpinMessage(sessionId, pinId) {
|
|
6617
|
-
const
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
if (
|
|
6626
|
-
|
|
6627
|
-
const el = document.getElementById('message-panel-pinned');
|
|
6628
|
-
if (el) el.innerHTML = renderPinnedSection();
|
|
6629
|
-
}
|
|
6630
|
-
} catch {}
|
|
6653
|
+
const pins = loadPins(sessionId);
|
|
6654
|
+
const idx = pins.findIndex((p) => p.id === pinId);
|
|
6655
|
+
if (idx < 0) return;
|
|
6656
|
+
pins.splice(idx, 1);
|
|
6657
|
+
savePins(sessionId, pins);
|
|
6658
|
+
if (currentSessionId === sessionId) {
|
|
6659
|
+
currentPins = pins;
|
|
6660
|
+
const el = document.getElementById('message-panel-pinned');
|
|
6661
|
+
if (el) el.innerHTML = renderPinnedSection();
|
|
6662
|
+
}
|
|
6631
6663
|
_renderStorageTab();
|
|
6632
6664
|
_updateStorageTotal();
|
|
6633
6665
|
}
|
|
@@ -6727,19 +6759,14 @@ function _storagePreviewScratchpad(key) {
|
|
|
6727
6759
|
|
|
6728
6760
|
function _storagePreviewPin(sessionId, pinId) {
|
|
6729
6761
|
closeStorageManager();
|
|
6730
|
-
const
|
|
6731
|
-
|
|
6732
|
-
|
|
6733
|
-
|
|
6734
|
-
|
|
6735
|
-
|
|
6736
|
-
|
|
6737
|
-
|
|
6738
|
-
_renderPinToDetail(pin);
|
|
6739
|
-
document.getElementById('msg-detail-modal').classList.add('visible');
|
|
6740
|
-
} catch (e) {
|
|
6741
|
-
console.error('_storagePreviewPin error:', e);
|
|
6742
|
-
}
|
|
6762
|
+
const pin = loadPins(sessionId).find((p) => p.id === pinId);
|
|
6763
|
+
if (!pin) return;
|
|
6764
|
+
document.getElementById('msg-detail-pin-btn').style.display = 'none';
|
|
6765
|
+
currentMsgDetailIdx = null;
|
|
6766
|
+
currentPinDetailId = null;
|
|
6767
|
+
syncMsgDetailPaneBtn();
|
|
6768
|
+
_renderPinToDetail(pin);
|
|
6769
|
+
document.getElementById('msg-detail-modal').classList.add('visible');
|
|
6743
6770
|
}
|
|
6744
6771
|
|
|
6745
6772
|
function _storageDeleteScratchpad(key) {
|
|
@@ -6832,7 +6859,7 @@ function _findOrphanedKeys(known) {
|
|
|
6832
6859
|
const orphaned = [];
|
|
6833
6860
|
for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
|
|
6834
6861
|
for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
|
|
6835
|
-
const sessionKeyPrefixes = [
|
|
6862
|
+
const sessionKeyPrefixes = [PINNED_MESSAGES_PREFIX, PAD_EMOJI_PREFIX, PREVIEW_STORAGE_PREFIX, PAD_LINKED_PREFIX];
|
|
6836
6863
|
for (const key of store.keys()) {
|
|
6837
6864
|
const pad = _parsePadKey(key);
|
|
6838
6865
|
if (pad?.kind === 'group') {
|
|
@@ -6859,23 +6886,28 @@ async function cleanupOrphanedStorage() {
|
|
|
6859
6886
|
try {
|
|
6860
6887
|
known = await _fetchKnownSessions();
|
|
6861
6888
|
} catch {
|
|
6862
|
-
showToast('
|
|
6889
|
+
showToast('Failed to list sessions — nothing was cleaned', 'error');
|
|
6863
6890
|
return;
|
|
6864
6891
|
}
|
|
6865
6892
|
const orphaned = _findOrphanedKeys(known);
|
|
6866
|
-
|
|
6893
|
+
const unpinned = new Set();
|
|
6867
6894
|
for (const key of orphaned) {
|
|
6868
6895
|
if (key.startsWith('__pinned__')) {
|
|
6869
|
-
|
|
6870
|
-
|
|
6896
|
+
const id = key.slice('__pinned__'.length);
|
|
6897
|
+
pinnedSessionIds.delete(id);
|
|
6898
|
+
unpinned.add(id);
|
|
6871
6899
|
} else if (key.startsWith('__sticky__')) {
|
|
6872
|
-
|
|
6873
|
-
|
|
6900
|
+
const id = key.slice('__sticky__'.length);
|
|
6901
|
+
stickySessionIds.delete(id);
|
|
6902
|
+
unpinned.add(id);
|
|
6874
6903
|
} else {
|
|
6875
6904
|
store.removeItem(key);
|
|
6905
|
+
// The server copy would bring the links back on the next merge.
|
|
6906
|
+
if (key.startsWith(PREVIEW_STORAGE_PREFIX)) forgetServerLinkedDoc(key.slice(PREVIEW_STORAGE_PREFIX.length));
|
|
6876
6907
|
}
|
|
6877
6908
|
}
|
|
6878
|
-
if (
|
|
6909
|
+
if (unpinned.size) savePinnedSessions();
|
|
6910
|
+
for (const id of unpinned) offloadSessionPin(id);
|
|
6879
6911
|
const removed = orphaned.length;
|
|
6880
6912
|
|
|
6881
6913
|
showToast(removed ? `Cleaned ${removed} orphaned item${removed > 1 ? 's' : ''}` : 'No orphaned items found');
|
|
@@ -6892,6 +6924,10 @@ function matchKey(e, ...keys) {
|
|
|
6892
6924
|
return keys.some((k) => e.key === k || e.code === k);
|
|
6893
6925
|
}
|
|
6894
6926
|
|
|
6927
|
+
function shiftOnly(e, code) {
|
|
6928
|
+
return e.code === code && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
|
|
6929
|
+
}
|
|
6930
|
+
|
|
6895
6931
|
function dismissSessionFromList(sid) {
|
|
6896
6932
|
const prevIdx = selectedSessionIdx;
|
|
6897
6933
|
if (!setSessionDismissed(sid, true)) return;
|
|
@@ -6940,10 +6976,10 @@ async function confirmDismissBlockers(sid) {
|
|
|
6940
6976
|
const has = [sticky && 'a sticky pin', terminal && 'a running terminal'].filter(Boolean).join(' and ');
|
|
6941
6977
|
const [title, verb, okLabel] =
|
|
6942
6978
|
sticky && terminal
|
|
6943
|
-
? ['Unpin and
|
|
6979
|
+
? ['Unpin and End Terminal', 'Unpin, end the terminal', 'Unpin, end and dismiss']
|
|
6944
6980
|
: sticky
|
|
6945
6981
|
? ['Remove Sticky Pin', 'Remove the pin', 'Unpin and dismiss']
|
|
6946
|
-
: ['
|
|
6982
|
+
: ['End Terminal', 'End the terminal', 'End and dismiss'];
|
|
6947
6983
|
const ok = await confirmModal({ title, message: `"${name}" has ${has}. ${verb} and dismiss the session?`, okLabel });
|
|
6948
6984
|
if (!ok) return false;
|
|
6949
6985
|
if (sticky) unpinSession(sid);
|
|
@@ -7041,11 +7077,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
7041
7077
|
} else if (e.key === '?' && document.getElementById('help-modal').classList.contains('visible')) {
|
|
7042
7078
|
e.preventDefault();
|
|
7043
7079
|
closeHelpModal();
|
|
7044
|
-
} else if (
|
|
7045
|
-
e.code === 'KeyM' &&
|
|
7046
|
-
e.shiftKey &&
|
|
7047
|
-
document.getElementById('msg-detail-modal').classList.contains('visible')
|
|
7048
|
-
) {
|
|
7080
|
+
} else if (shiftOnly(e, 'KeyM') && document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
7049
7081
|
e.preventDefault();
|
|
7050
7082
|
closeMsgDetailModal();
|
|
7051
7083
|
} else if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
@@ -7108,6 +7140,10 @@ document.addEventListener('keydown', (e) => {
|
|
|
7108
7140
|
e.preventDefault();
|
|
7109
7141
|
return;
|
|
7110
7142
|
}
|
|
7143
|
+
if (isMenuKey(e) && ((focusZone === 'sidebar' && sgOpenMenuForKbSelection()) || openFocusedPaneMenu())) {
|
|
7144
|
+
e.preventDefault();
|
|
7145
|
+
return;
|
|
7146
|
+
}
|
|
7111
7147
|
const plainShift = !e.ctrlKey && !e.altKey && !e.metaKey;
|
|
7112
7148
|
if (plainShift && e.key === '|' && togglePaneSplit()) {
|
|
7113
7149
|
e.preventDefault();
|
|
@@ -7117,17 +7153,17 @@ document.addEventListener('keydown', (e) => {
|
|
|
7117
7153
|
e.preventDefault();
|
|
7118
7154
|
return;
|
|
7119
7155
|
}
|
|
7120
|
-
if (e
|
|
7156
|
+
if (shiftOnly(e, 'KeyL')) {
|
|
7121
7157
|
e.preventDefault();
|
|
7122
7158
|
toggleMessagePanel();
|
|
7123
7159
|
return;
|
|
7124
7160
|
}
|
|
7125
|
-
if (e
|
|
7161
|
+
if (shiftOnly(e, 'KeyP')) {
|
|
7126
7162
|
e.preventDefault();
|
|
7127
7163
|
openProjectPicker();
|
|
7128
7164
|
return;
|
|
7129
7165
|
}
|
|
7130
|
-
if (e
|
|
7166
|
+
if (shiftOnly(e, 'KeyM')) {
|
|
7131
7167
|
e.preventDefault();
|
|
7132
7168
|
const msgDetailModal = document.getElementById('msg-detail-modal');
|
|
7133
7169
|
if (msgDetailModal.classList.contains('visible')) {
|
|
@@ -7141,12 +7177,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
7141
7177
|
}
|
|
7142
7178
|
return;
|
|
7143
7179
|
}
|
|
7144
|
-
if (e
|
|
7180
|
+
if (shiftOnly(e, 'KeyS')) {
|
|
7145
7181
|
e.preventDefault();
|
|
7146
7182
|
showStorageManager();
|
|
7147
7183
|
return;
|
|
7148
7184
|
}
|
|
7149
|
-
if (e
|
|
7185
|
+
if (shiftOnly(e, 'KeyZ')) {
|
|
7150
7186
|
e.preventDefault();
|
|
7151
7187
|
toggleZenMode();
|
|
7152
7188
|
return;
|
|
@@ -7202,11 +7238,6 @@ document.addEventListener('keydown', (e) => {
|
|
|
7202
7238
|
activateSelectedSession();
|
|
7203
7239
|
return;
|
|
7204
7240
|
}
|
|
7205
|
-
const menuKey = e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey);
|
|
7206
|
-
if (menuKey && sgOpenMenuForKbSelection()) {
|
|
7207
|
-
e.preventDefault();
|
|
7208
|
-
return;
|
|
7209
|
-
}
|
|
7210
7241
|
}
|
|
7211
7242
|
|
|
7212
7243
|
// Board navigation
|
|
@@ -7271,17 +7302,17 @@ document.addEventListener('keydown', (e) => {
|
|
|
7271
7302
|
const cursorEl = focusZone === 'sidebar' ? sessionsList.querySelector('.kb-selected') : null;
|
|
7272
7303
|
const contextSid =
|
|
7273
7304
|
focusZone === 'sidebar' ? cursorEl?.dataset.sessionId || currentSessionId : selectedSessionId || currentSessionId;
|
|
7274
|
-
if (matchKey(e, 'KeyP')
|
|
7305
|
+
if (matchKey(e, 'KeyP')) {
|
|
7275
7306
|
e.preventDefault();
|
|
7276
7307
|
if (contextSid) openPlanForSession(contextSid);
|
|
7277
7308
|
return;
|
|
7278
7309
|
}
|
|
7279
|
-
if (matchKey(e, 'KeyI')
|
|
7310
|
+
if (matchKey(e, 'KeyI')) {
|
|
7280
7311
|
e.preventDefault();
|
|
7281
7312
|
if (contextSid) showSessionInfoModal(contextSid);
|
|
7282
7313
|
return;
|
|
7283
7314
|
}
|
|
7284
|
-
if (matchKey(e, 'KeyN')
|
|
7315
|
+
if (matchKey(e, 'KeyN')) {
|
|
7285
7316
|
e.preventDefault();
|
|
7286
7317
|
const cursorKey = _scratchpadKeyOf(cursorEl);
|
|
7287
7318
|
if (cursorKey) showScratchpad(cursorKey);
|
|
@@ -7299,13 +7330,13 @@ document.addEventListener('keydown', (e) => {
|
|
|
7299
7330
|
openMarketplace(mSession?.project);
|
|
7300
7331
|
return;
|
|
7301
7332
|
}
|
|
7302
|
-
if (
|
|
7333
|
+
if (ClaudeHub.comboOf(e) === 'ctrl+m') {
|
|
7303
7334
|
e.preventDefault();
|
|
7304
7335
|
const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
|
|
7305
7336
|
openMemory(mSession?.project);
|
|
7306
7337
|
return;
|
|
7307
7338
|
}
|
|
7308
|
-
if (
|
|
7339
|
+
if (ClaudeHub.comboOf(e) === 'ctrl+d') {
|
|
7309
7340
|
e.preventDefault();
|
|
7310
7341
|
if (!contextSid) return;
|
|
7311
7342
|
dismissSession(contextSid);
|
|
@@ -7325,10 +7356,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
7325
7356
|
showToast('No live session name: the session is not running');
|
|
7326
7357
|
return;
|
|
7327
7358
|
}
|
|
7328
|
-
|
|
7329
|
-
.writeText(text)
|
|
7330
|
-
.then(() => showToast(`Copied ${label}: ${e.ctrlKey ? text : text.slice(0, 8)}`, 'success'))
|
|
7331
|
-
.catch(() => showToast(`Failed to copy ${label}`));
|
|
7359
|
+
copyText(text, label, `Copied ${label}: ${e.ctrlKey ? text : text.slice(0, 8)}`);
|
|
7332
7360
|
return;
|
|
7333
7361
|
}
|
|
7334
7362
|
if (matchKey(e, 'KeyR')) {
|
|
@@ -7965,7 +7993,7 @@ async function sendReview() {
|
|
|
7965
7993
|
const items = reviewItems();
|
|
7966
7994
|
if (!items.length) return;
|
|
7967
7995
|
try {
|
|
7968
|
-
const res = await terminalFetch(`/api/sessions/${
|
|
7996
|
+
const res = await terminalFetch(apiPath`/api/sessions/${sessionId}/review`, 'POST', {
|
|
7969
7997
|
source,
|
|
7970
7998
|
comments: items.map(({ quote, comment, context }) => ({ quote, comment, ...context })),
|
|
7971
7999
|
});
|
|
@@ -7975,14 +8003,19 @@ async function sendReview() {
|
|
|
7975
8003
|
else if (data.delivered === 'terminal') showToast('Pasted into the terminal: press Enter there to send', 'info');
|
|
7976
8004
|
else {
|
|
7977
8005
|
const { markdown } = data;
|
|
7978
|
-
const copy =
|
|
8006
|
+
const copy = async () => {
|
|
8007
|
+
const copied = await navigator.clipboard.writeText(markdown).then(
|
|
8008
|
+
() => true,
|
|
8009
|
+
() => false,
|
|
8010
|
+
);
|
|
8011
|
+
showToast(
|
|
8012
|
+
copied ? 'Copied the review. Paste it into the session.' : 'Failed to copy the review',
|
|
8013
|
+
'info',
|
|
8014
|
+
{ label: 'Copy again', onClick: copy },
|
|
8015
|
+
'Sessions get reviews directly when the cck plugin runs there and board events are on.',
|
|
8016
|
+
);
|
|
8017
|
+
};
|
|
7979
8018
|
await copy();
|
|
7980
|
-
showToast(
|
|
7981
|
-
'Review copied. Paste it into the session.',
|
|
7982
|
-
'info',
|
|
7983
|
-
{ label: 'Copy again', onClick: copy },
|
|
7984
|
-
'Run /claude-code-kanban:follow there to send reviews directly.',
|
|
7985
|
-
);
|
|
7986
8019
|
}
|
|
7987
8020
|
clearReviewDrafts(key);
|
|
7988
8021
|
if (activeReview?.key === key) onSent?.();
|
|
@@ -7997,12 +8030,8 @@ const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
|
|
|
7997
8030
|
let currentPreviewPath = null;
|
|
7998
8031
|
|
|
7999
8032
|
function readStoredList(key) {
|
|
8000
|
-
|
|
8001
|
-
|
|
8002
|
-
return Array.isArray(arr) ? arr : [];
|
|
8003
|
-
} catch {
|
|
8004
|
-
return [];
|
|
8005
|
-
}
|
|
8033
|
+
const arr = store.readJson(key);
|
|
8034
|
+
return Array.isArray(arr) ? arr : [];
|
|
8006
8035
|
}
|
|
8007
8036
|
|
|
8008
8037
|
function getSessionPreviewPaths(sessionId) {
|
|
@@ -8036,7 +8065,7 @@ function removeSessionPreviewPath(sessionId, filePath) {
|
|
|
8036
8065
|
}
|
|
8037
8066
|
|
|
8038
8067
|
function saveSessionPreviewPaths(sessionId, paths) {
|
|
8039
|
-
if (paths.length) store.
|
|
8068
|
+
if (paths.length) store.writeJson(PREVIEW_STORAGE_PREFIX + sessionId, paths.slice(0, MAX_LINKED_DOCS));
|
|
8040
8069
|
else store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
8041
8070
|
}
|
|
8042
8071
|
|
|
@@ -8053,14 +8082,7 @@ function setSessionDocLink(sessionId, filePath, unlink) {
|
|
|
8053
8082
|
|
|
8054
8083
|
// Links sent while no tab was open reach the browser only through the server copy.
|
|
8055
8084
|
async function mergeServerLinkedDocs() {
|
|
8056
|
-
|
|
8057
|
-
try {
|
|
8058
|
-
const res = await fetch('/api/document/links');
|
|
8059
|
-
if (!res.ok) return;
|
|
8060
|
-
bySession = await res.json();
|
|
8061
|
-
} catch {
|
|
8062
|
-
return;
|
|
8063
|
-
}
|
|
8085
|
+
const bySession = await getJson('/api/document/links', {});
|
|
8064
8086
|
for (const [sessionId, paths] of Object.entries(bySession || {})) {
|
|
8065
8087
|
const local = getSessionPreviewPaths(sessionId);
|
|
8066
8088
|
const known = new Set(local.map(canonicalPath));
|
|
@@ -8072,8 +8094,9 @@ async function mergeServerLinkedDocs() {
|
|
|
8072
8094
|
}
|
|
8073
8095
|
|
|
8074
8096
|
function forgetServerLinkedDoc(sessionId, filePath) {
|
|
8075
|
-
|
|
8076
|
-
|
|
8097
|
+
api(apiPath`/api/document/links/${sessionId}`, { method: 'DELETE', query: { path: filePath || null } }).catch(
|
|
8098
|
+
() => {},
|
|
8099
|
+
);
|
|
8077
8100
|
}
|
|
8078
8101
|
|
|
8079
8102
|
function afterLinkedDocsChanged(sessionId) {
|
|
@@ -8154,13 +8177,24 @@ function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
|
|
|
8154
8177
|
// backtracking on the main thread, and .jsonl/.log files in a scratchpad reach megabytes.
|
|
8155
8178
|
const HLJS_MAX_CHARS = 256 * 1024;
|
|
8156
8179
|
|
|
8180
|
+
function langOfPath(path) {
|
|
8181
|
+
return typeof path === 'string' ? (path.split('.').pop() || '').toLowerCase() : '';
|
|
8182
|
+
}
|
|
8183
|
+
|
|
8184
|
+
function highlightCode(text, lang) {
|
|
8185
|
+
if (typeof hljs === 'undefined' || text.length > HLJS_MAX_CHARS || !hljs.getLanguage(lang)) return null;
|
|
8186
|
+
try {
|
|
8187
|
+
return hljs.highlight(text, { language: lang, ignoreIllegals: true }).value;
|
|
8188
|
+
} catch (_) {
|
|
8189
|
+
return null;
|
|
8190
|
+
}
|
|
8191
|
+
}
|
|
8192
|
+
|
|
8157
8193
|
function renderSourcePreview(filePath, content) {
|
|
8158
|
-
const ext = (filePath
|
|
8159
|
-
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
return `<pre class="preview-source"><code class="hljs language-${escapeHtml(ext)}">${value}</code></pre>`;
|
|
8163
|
-
} catch (_) {}
|
|
8194
|
+
const ext = langOfPath(filePath);
|
|
8195
|
+
const value = highlightCode(content, ext);
|
|
8196
|
+
if (value != null) {
|
|
8197
|
+
return `<pre class="preview-source"><code class="hljs language-${escapeHtml(ext)}">${value}</code></pre>`;
|
|
8164
8198
|
}
|
|
8165
8199
|
return `<pre class="preview-source"><code class="hljs">${escapeHtml(content)}</code></pre>`;
|
|
8166
8200
|
}
|
|
@@ -8235,7 +8269,7 @@ function updatePreviewLinkBtn() {
|
|
|
8235
8269
|
btn.style.display = '';
|
|
8236
8270
|
const linked = isPreviewLinkedToCurrentSession();
|
|
8237
8271
|
btn.title = linked ? 'Unlink from current session' : 'Link to current session';
|
|
8238
|
-
btn.style.color = linked ? 'var(--accent
|
|
8272
|
+
btn.style.color = linked ? 'var(--accent)' : '';
|
|
8239
8273
|
}
|
|
8240
8274
|
|
|
8241
8275
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
@@ -8290,16 +8324,14 @@ function openPreviewInEditor() {
|
|
|
8290
8324
|
async function openPreviewByPath(filePath, base, onUnsupported) {
|
|
8291
8325
|
if (!filePath) return;
|
|
8292
8326
|
try {
|
|
8293
|
-
const
|
|
8294
|
-
if (base) qs.set('base', base);
|
|
8295
|
-
const r = await fetch(`/api/preview?${qs}`);
|
|
8327
|
+
const r = await api('/api/preview', { query: { path: filePath, base: base || null } });
|
|
8296
8328
|
if (!r.ok) {
|
|
8297
|
-
showToast('Preview file unavailable');
|
|
8329
|
+
showToast('Preview file unavailable', 'error');
|
|
8298
8330
|
return;
|
|
8299
8331
|
}
|
|
8300
8332
|
const data = await r.json();
|
|
8301
8333
|
if (data.exists === false) {
|
|
8302
|
-
showToast('File not found');
|
|
8334
|
+
showToast('File not found', 'error');
|
|
8303
8335
|
return;
|
|
8304
8336
|
}
|
|
8305
8337
|
if (data.kind === null) {
|
|
@@ -8309,7 +8341,7 @@ async function openPreviewByPath(filePath, base, onUnsupported) {
|
|
|
8309
8341
|
}
|
|
8310
8342
|
openPreviewModal(data.path, data.content, data.kind);
|
|
8311
8343
|
} catch {
|
|
8312
|
-
showToast('Failed to load preview');
|
|
8344
|
+
showToast('Failed to load preview', 'error');
|
|
8313
8345
|
}
|
|
8314
8346
|
}
|
|
8315
8347
|
|
|
@@ -8381,12 +8413,8 @@ async function loadPreviewKinds(sessionId, paths) {
|
|
|
8381
8413
|
try {
|
|
8382
8414
|
await Promise.all(
|
|
8383
8415
|
pending.map(async (p) => {
|
|
8384
|
-
|
|
8385
|
-
|
|
8386
|
-
const r = await fetch(`/api/file/resolve?path=${encodeURIComponent(p)}`);
|
|
8387
|
-
if (r.ok) kind = (await r.json()).kind;
|
|
8388
|
-
} catch (_) {}
|
|
8389
|
-
previewKindByPath.set(p, kind);
|
|
8416
|
+
const data = await getJson('/api/file/resolve', {}, { query: { path: p } });
|
|
8417
|
+
previewKindByPath.set(p, data.kind ?? null);
|
|
8390
8418
|
}),
|
|
8391
8419
|
);
|
|
8392
8420
|
} finally {
|
|
@@ -8406,21 +8434,18 @@ function getOfferedPadPaths(sessionId) {
|
|
|
8406
8434
|
}
|
|
8407
8435
|
|
|
8408
8436
|
function setOfferedPadPaths(sessionId, paths) {
|
|
8409
|
-
store.
|
|
8437
|
+
store.writeJson(PAD_LINKED_PREFIX + sessionId, paths);
|
|
8410
8438
|
}
|
|
8411
8439
|
|
|
8412
8440
|
async function loadSessionPads(sessionId) {
|
|
8413
8441
|
if (!sessionId || padsFetched.has(sessionId)) return;
|
|
8414
8442
|
padsFetched.add(sessionId);
|
|
8415
|
-
|
|
8416
|
-
|
|
8417
|
-
const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/pads`);
|
|
8418
|
-
if (!r.ok) throw new Error(r.status);
|
|
8419
|
-
pads = (await r.json()).pads || [];
|
|
8420
|
-
} catch (_) {
|
|
8443
|
+
const data = await getJson(apiPath`/api/sessions/${sessionId}/pads`);
|
|
8444
|
+
if (!data) {
|
|
8421
8445
|
padsFetched.delete(sessionId);
|
|
8422
8446
|
return;
|
|
8423
8447
|
}
|
|
8448
|
+
const pads = data.pads || [];
|
|
8424
8449
|
const offered = new Set(getOfferedPadPaths(sessionId).map(canonicalPath));
|
|
8425
8450
|
const fresh = pads.map((p) => p.path).filter((p) => !offered.has(canonicalPath(p)));
|
|
8426
8451
|
if (!fresh.length) return;
|
|
@@ -8583,15 +8608,10 @@ function makeSectionLoader({ attr, endpoint, pick, innerHtml, ttlMs = 0 }) {
|
|
|
8583
8608
|
const cached = store.get(sessionId);
|
|
8584
8609
|
if (cached && Date.now() - cached.at < ttlMs) return;
|
|
8585
8610
|
inFlight.add(sessionId);
|
|
8586
|
-
|
|
8587
|
-
|
|
8588
|
-
|
|
8589
|
-
|
|
8590
|
-
} catch (_) {
|
|
8591
|
-
return;
|
|
8592
|
-
} finally {
|
|
8593
|
-
inFlight.delete(sessionId);
|
|
8594
|
-
}
|
|
8611
|
+
const data = await getJson(apiPath`/api/sessions/${sessionId}/${endpoint}`);
|
|
8612
|
+
inFlight.delete(sessionId);
|
|
8613
|
+
if (!data) return;
|
|
8614
|
+
store.set(sessionId, { at: Date.now(), data: pick(data) });
|
|
8595
8615
|
paintSectionSlots(attr, sessionId, innerHtml(sessionId));
|
|
8596
8616
|
},
|
|
8597
8617
|
};
|
|
@@ -8841,13 +8861,9 @@ async function toggleScratchFolder(sessionId, dirPath) {
|
|
|
8841
8861
|
}
|
|
8842
8862
|
scratchFolders.set(dirPath, { status: 'loading', children: [] });
|
|
8843
8863
|
scratchFilesSection.repaint(sessionId);
|
|
8844
|
-
|
|
8845
|
-
|
|
8846
|
-
|
|
8847
|
-
if (res.ok) entry = { status: 'ready', children: (await res.json()).files || [] };
|
|
8848
|
-
} catch (_) {
|
|
8849
|
-
// Reads "Failed to load"; closing and reopening the folder retries.
|
|
8850
|
-
}
|
|
8864
|
+
const data = await getJson(apiPath`/api/sessions/${sessionId}/scratchpad-files`, null, { query: { path: dirPath } });
|
|
8865
|
+
// An error entry reads "Failed to load"; closing and reopening the folder retries.
|
|
8866
|
+
const entry = data ? { status: 'ready', children: data.files || [] } : { status: 'error', children: [] };
|
|
8851
8867
|
// The user may have closed the folder while the request was in flight.
|
|
8852
8868
|
if (scratchFolders.has(dirPath)) {
|
|
8853
8869
|
scratchFolders.set(dirPath, entry);
|
|
@@ -8948,10 +8964,7 @@ async function linkFileByPath(sessionId, raw, slot) {
|
|
|
8948
8964
|
}
|
|
8949
8965
|
} else {
|
|
8950
8966
|
try {
|
|
8951
|
-
const
|
|
8952
|
-
const base = getSessionBaseDir(sessionId);
|
|
8953
|
-
if (base) qs.set('base', base);
|
|
8954
|
-
const r = await fetch(`/api/file/resolve?${qs}`);
|
|
8967
|
+
const r = await api('/api/file/resolve', { query: { path: value, base: getSessionBaseDir(sessionId) || null } });
|
|
8955
8968
|
const data = await r.json().catch(() => ({}));
|
|
8956
8969
|
if (!r.ok || !data.exists) {
|
|
8957
8970
|
fail(data.error || 'File not found');
|
|
@@ -9178,7 +9191,12 @@ function setupEventSource() {
|
|
|
9178
9191
|
connect();
|
|
9179
9192
|
}
|
|
9180
9193
|
|
|
9181
|
-
const CONTEXT_COLORS = {
|
|
9194
|
+
const CONTEXT_COLORS = {
|
|
9195
|
+
green: 'var(--success)',
|
|
9196
|
+
yellow: 'var(--warning)',
|
|
9197
|
+
orange: 'color-mix(in srgb, var(--warning), var(--danger))',
|
|
9198
|
+
red: 'var(--danger)',
|
|
9199
|
+
};
|
|
9182
9200
|
const COST_THRESHOLDS = { green: 0.5, yellow: 2, orange: 5 };
|
|
9183
9201
|
const MODEL_THRESHOLDS = [
|
|
9184
9202
|
{ match: /sonnet/i, yellow: 120000, orange: 200000, red: 300000 },
|
|
@@ -9638,6 +9656,7 @@ function showWaitingDetail() {
|
|
|
9638
9656
|
document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
|
|
9639
9657
|
currentPinDetailId = null;
|
|
9640
9658
|
updateMsgDetailPinState();
|
|
9659
|
+
syncMsgDetailPaneBtn();
|
|
9641
9660
|
overlay.classList.add('visible');
|
|
9642
9661
|
}
|
|
9643
9662
|
|
|
@@ -9895,10 +9914,7 @@ function renderMarkdown(text) {
|
|
|
9895
9914
|
}
|
|
9896
9915
|
|
|
9897
9916
|
function isLightTheme() {
|
|
9898
|
-
|
|
9899
|
-
return (
|
|
9900
|
-
document.body.classList.contains('light') || (!saved && window.matchMedia('(prefers-color-scheme: light)').matches)
|
|
9901
|
-
);
|
|
9917
|
+
return document.body.classList.contains('light');
|
|
9902
9918
|
}
|
|
9903
9919
|
|
|
9904
9920
|
function getMermaidTheme() {
|
|
@@ -10004,28 +10020,13 @@ async function copyAgentTabActive(groupId, btn) {
|
|
|
10004
10020
|
copyAgentTab(key, btn);
|
|
10005
10021
|
}
|
|
10006
10022
|
|
|
10007
|
-
const
|
|
10008
|
-
|
|
10009
|
-
|
|
10010
|
-
|
|
10011
|
-
|
|
10012
|
-
|
|
10013
|
-
|
|
10014
|
-
{ bg: 'rgba(22, 163, 74, 0.14)', color: '#15803d' }, // green
|
|
10015
|
-
{ bg: 'rgba(99, 102, 241, 0.14)', color: '#4f46e5' }, // indigo
|
|
10016
|
-
];
|
|
10017
|
-
const namedColorMap = {
|
|
10018
|
-
red: { bg: 'rgba(239, 68, 68, 0.14)', color: '#dc2626' },
|
|
10019
|
-
blue: { bg: 'rgba(37, 99, 235, 0.14)', color: '#1d5bbf' },
|
|
10020
|
-
green: { bg: 'rgba(22, 163, 74, 0.14)', color: '#15803d' },
|
|
10021
|
-
purple: { bg: 'rgba(168, 85, 247, 0.14)', color: '#7c3aed' },
|
|
10022
|
-
orange: { bg: 'rgba(234, 88, 12, 0.14)', color: '#c2410c' },
|
|
10023
|
-
pink: { bg: 'rgba(219, 39, 119, 0.14)', color: '#b5246a' },
|
|
10024
|
-
yellow: { bg: 'rgba(202, 138, 4, 0.14)', color: '#92700c' },
|
|
10025
|
-
teal: { bg: 'rgba(14, 165, 133, 0.14)', color: '#0d7d65' },
|
|
10026
|
-
indigo: { bg: 'rgba(99, 102, 241, 0.14)', color: '#4f46e5' },
|
|
10027
|
-
cyan: { bg: 'rgba(6, 182, 212, 0.14)', color: '#0891b2' },
|
|
10028
|
-
};
|
|
10023
|
+
const ownerColor = (hue) => ({
|
|
10024
|
+
bg: `color-mix(in srgb, var(--owner-${hue}) 13%, transparent)`,
|
|
10025
|
+
color: `var(--owner-${hue})`,
|
|
10026
|
+
});
|
|
10027
|
+
const OWNER_HUES = ['blue', 'purple', 'teal', 'orange', 'yellow', 'pink', 'green', 'indigo'];
|
|
10028
|
+
const ownerColors = OWNER_HUES.map(ownerColor);
|
|
10029
|
+
const namedColorMap = Object.fromEntries([...OWNER_HUES, 'red', 'cyan'].map((h) => [h, ownerColor(h)]));
|
|
10029
10030
|
const ownerColorCache = {};
|
|
10030
10031
|
const teamColorMap = {};
|
|
10031
10032
|
function isInternalTask(task) {
|
|
@@ -10337,7 +10338,7 @@ document.addEventListener('click', (e) => {
|
|
|
10337
10338
|
if (breadcrumb) {
|
|
10338
10339
|
e.stopPropagation();
|
|
10339
10340
|
const path = breadcrumb.dataset.fullPath;
|
|
10340
|
-
if (path)
|
|
10341
|
+
if (path) copyText(path, 'project path');
|
|
10341
10342
|
return;
|
|
10342
10343
|
}
|
|
10343
10344
|
|
|
@@ -10358,13 +10359,13 @@ document.addEventListener('click', (e) => {
|
|
|
10358
10359
|
|
|
10359
10360
|
if (e.target.closest('.pinned-ungroup-btn')) {
|
|
10360
10361
|
e.stopPropagation();
|
|
10361
|
-
store.setItem(
|
|
10362
|
+
store.setItem(GROUP_PINNED_KEY, 'false');
|
|
10362
10363
|
renderSessions();
|
|
10363
10364
|
return;
|
|
10364
10365
|
}
|
|
10365
10366
|
|
|
10366
10367
|
if (e.target.closest('.pinned-regroup-banner')) {
|
|
10367
|
-
store.setItem(
|
|
10368
|
+
store.setItem(GROUP_PINNED_KEY, 'true');
|
|
10368
10369
|
renderSessions();
|
|
10369
10370
|
return;
|
|
10370
10371
|
}
|
|
@@ -10380,10 +10381,15 @@ document.addEventListener('click', (e) => {
|
|
|
10380
10381
|
}
|
|
10381
10382
|
if (sgHeader.dataset.transientGroup && e.target.closest('.sg-delete')) {
|
|
10382
10383
|
const name = sgHeader.dataset.transientGroup;
|
|
10383
|
-
|
|
10384
|
+
confirmModal({
|
|
10385
|
+
title: 'Delete Group',
|
|
10386
|
+
message: `Delete group “${name}”? Its sessions go back to Projects.`,
|
|
10387
|
+
okLabel: 'Delete',
|
|
10388
|
+
}).then((ok) => {
|
|
10389
|
+
if (!ok) return;
|
|
10384
10390
|
sgDeleteTransient(name);
|
|
10385
10391
|
renderSessions();
|
|
10386
|
-
}
|
|
10392
|
+
});
|
|
10387
10393
|
return;
|
|
10388
10394
|
}
|
|
10389
10395
|
const groupId = sgHeader.dataset.groupId;
|
|
@@ -10394,10 +10400,15 @@ document.addEventListener('click', (e) => {
|
|
|
10394
10400
|
return;
|
|
10395
10401
|
}
|
|
10396
10402
|
if (e.target.closest('.sg-delete')) {
|
|
10397
|
-
|
|
10403
|
+
confirmModal({
|
|
10404
|
+
title: 'Delete Group',
|
|
10405
|
+
message: `Delete group “${group.name}”? Its sessions and projects go back to Projects.`,
|
|
10406
|
+
okLabel: 'Delete',
|
|
10407
|
+
}).then((ok) => {
|
|
10408
|
+
if (!ok) return;
|
|
10398
10409
|
sgDeleteGroup(groupId);
|
|
10399
10410
|
renderSessions();
|
|
10400
|
-
}
|
|
10411
|
+
});
|
|
10401
10412
|
return;
|
|
10402
10413
|
}
|
|
10403
10414
|
} else if (!sgHeader.dataset.transientGroup) return;
|
|
@@ -10442,15 +10453,9 @@ let projectsCache = null;
|
|
|
10442
10453
|
|
|
10443
10454
|
async function loadProjects() {
|
|
10444
10455
|
if (!projectsCacheDirty && projectsCache) return projectsCache;
|
|
10445
|
-
|
|
10446
|
-
|
|
10447
|
-
|
|
10448
|
-
projects = await res.json();
|
|
10449
|
-
} catch (_e) {
|
|
10450
|
-
projects = [...new Set(sessions.map((s) => s.project).filter(Boolean))]
|
|
10451
|
-
.sort()
|
|
10452
|
-
.map((p) => ({ path: p, modifiedAt: null }));
|
|
10453
|
-
}
|
|
10456
|
+
const projects =
|
|
10457
|
+
(await getJson('/api/projects')) ??
|
|
10458
|
+
[...new Set(sessions.map((s) => s.project).filter(Boolean))].sort().map((p) => ({ path: p, modifiedAt: null }));
|
|
10454
10459
|
projectsCache = projects;
|
|
10455
10460
|
projectsCacheDirty = false;
|
|
10456
10461
|
return projects;
|
|
@@ -10489,52 +10494,36 @@ function updateThemeColor(isLight) {
|
|
|
10489
10494
|
//#endregion
|
|
10490
10495
|
|
|
10491
10496
|
//#region THEME
|
|
10492
|
-
function
|
|
10493
|
-
|
|
10494
|
-
|
|
10495
|
-
|
|
10496
|
-
|
|
10497
|
-
|
|
10498
|
-
} else {
|
|
10499
|
-
document.body.classList.add('light');
|
|
10500
|
-
document.body.classList.remove('dark-forced');
|
|
10501
|
-
store.setItem('theme', 'light');
|
|
10502
|
-
}
|
|
10503
|
-
updateThemeIcon();
|
|
10504
|
-
updateThemeColor(!isCurrentlyLight);
|
|
10505
|
-
syncHljsTheme();
|
|
10506
|
-
reinitMermaidTheme();
|
|
10507
|
-
}
|
|
10508
|
-
|
|
10509
|
-
function syncHljsTheme() {
|
|
10510
|
-
const light = isLightTheme();
|
|
10497
|
+
function applyTheme(light) {
|
|
10498
|
+
document.body.classList.toggle('light', light);
|
|
10499
|
+
document.body.classList.toggle('dark-forced', !light);
|
|
10500
|
+
document.getElementById('theme-icon-dark').style.display = light ? 'none' : 'block';
|
|
10501
|
+
document.getElementById('theme-icon-light').style.display = light ? 'block' : 'none';
|
|
10502
|
+
updateThemeColor(light);
|
|
10511
10503
|
const dark$ = document.getElementById('hljs-theme-dark');
|
|
10512
10504
|
const light$ = document.getElementById('hljs-theme-light');
|
|
10513
10505
|
if (dark$) dark$.disabled = light;
|
|
10514
10506
|
if (light$) light$.disabled = !light;
|
|
10507
|
+
reinitMermaidTheme();
|
|
10515
10508
|
}
|
|
10516
10509
|
|
|
10517
|
-
function
|
|
10518
|
-
const light = isLightTheme();
|
|
10519
|
-
|
|
10520
|
-
|
|
10510
|
+
function toggleTheme() {
|
|
10511
|
+
const light = !isLightTheme();
|
|
10512
|
+
store.setItem(THEME_KEY, light ? 'light' : 'dark');
|
|
10513
|
+
applyTheme(light);
|
|
10521
10514
|
}
|
|
10522
10515
|
|
|
10523
10516
|
function loadTheme() {
|
|
10524
|
-
const saved = store.getItem(
|
|
10525
|
-
|
|
10526
|
-
|
|
10527
|
-
|
|
10528
|
-
|
|
10529
|
-
|
|
10530
|
-
|
|
10531
|
-
}
|
|
10532
|
-
// If no saved preference, system prefers-color-scheme CSS handles it
|
|
10533
|
-
updateThemeIcon();
|
|
10534
|
-
updateThemeColor(document.body.classList.contains('light'));
|
|
10535
|
-
syncHljsTheme();
|
|
10517
|
+
const saved = store.getItem(THEME_KEY);
|
|
10518
|
+
const system = window.matchMedia('(prefers-color-scheme: light)');
|
|
10519
|
+
applyTheme(saved ? saved === 'light' : system.matches);
|
|
10520
|
+
// The prefers-color-scheme CSS only covers the first paint; the class decides after that, so it
|
|
10521
|
+
// follows the system until the user picks a theme.
|
|
10522
|
+
system.addEventListener('change', (e) => {
|
|
10523
|
+
if (!store.getItem(THEME_KEY)) applyTheme(e.matches);
|
|
10524
|
+
});
|
|
10536
10525
|
buildThemeMenu();
|
|
10537
|
-
const colorTheme = store.getItem(
|
|
10526
|
+
const colorTheme = store.getItem(COLOR_THEME_KEY);
|
|
10538
10527
|
if (colorTheme) document.body.dataset.colorTheme = colorTheme;
|
|
10539
10528
|
syncColorThemeSelect(colorTheme || 'ember');
|
|
10540
10529
|
}
|
|
@@ -10592,10 +10581,10 @@ function syncColorThemeSelect(id) {
|
|
|
10592
10581
|
function setColorTheme(id) {
|
|
10593
10582
|
if (!id || id === 'ember') {
|
|
10594
10583
|
delete document.body.dataset.colorTheme;
|
|
10595
|
-
store.removeItem(
|
|
10584
|
+
store.removeItem(COLOR_THEME_KEY);
|
|
10596
10585
|
} else {
|
|
10597
10586
|
document.body.dataset.colorTheme = id;
|
|
10598
|
-
store.setItem(
|
|
10587
|
+
store.setItem(COLOR_THEME_KEY, id);
|
|
10599
10588
|
}
|
|
10600
10589
|
syncColorThemeSelect(id);
|
|
10601
10590
|
}
|
|
@@ -10607,13 +10596,13 @@ function expandSidebar() {
|
|
|
10607
10596
|
const sidebar = document.querySelector('.sidebar');
|
|
10608
10597
|
if (!sidebar.classList.contains('collapsed')) return;
|
|
10609
10598
|
sidebar.classList.remove('collapsed');
|
|
10610
|
-
store.setItem(
|
|
10599
|
+
store.setItem(SIDEBAR_COLLAPSED_KEY, false);
|
|
10611
10600
|
}
|
|
10612
10601
|
|
|
10613
10602
|
function toggleSidebar() {
|
|
10614
10603
|
const sidebar = document.querySelector('.sidebar');
|
|
10615
10604
|
const collapsed = sidebar.classList.toggle('collapsed');
|
|
10616
|
-
store.setItem(
|
|
10605
|
+
store.setItem(SIDEBAR_COLLAPSED_KEY, collapsed);
|
|
10617
10606
|
if (collapsed) {
|
|
10618
10607
|
sidebar.style.width = '';
|
|
10619
10608
|
if (focusZone === 'sidebar') setFocusZone('board');
|
|
@@ -10625,10 +10614,10 @@ function toggleSidebar() {
|
|
|
10625
10614
|
|
|
10626
10615
|
function loadSidebarState() {
|
|
10627
10616
|
const sidebar = document.querySelector('.sidebar');
|
|
10628
|
-
if (store.getItem(
|
|
10617
|
+
if (store.getItem(SIDEBAR_COLLAPSED_KEY) === 'true') {
|
|
10629
10618
|
sidebar.classList.add('collapsed');
|
|
10630
10619
|
}
|
|
10631
|
-
const w = store.getItem(
|
|
10620
|
+
const w = store.getItem(SIDEBAR_WIDTH_KEY);
|
|
10632
10621
|
if (w) {
|
|
10633
10622
|
sidebar.style.setProperty('--sidebar-width', w);
|
|
10634
10623
|
}
|
|
@@ -10683,12 +10672,12 @@ function initSidebarResize() {
|
|
|
10683
10672
|
},
|
|
10684
10673
|
onEnd() {
|
|
10685
10674
|
sidebar.classList.remove('resizing');
|
|
10686
|
-
store.setItem(
|
|
10675
|
+
store.setItem(SIDEBAR_WIDTH_KEY, sidebar.style.getPropertyValue('--sidebar-width'));
|
|
10687
10676
|
},
|
|
10688
10677
|
});
|
|
10689
10678
|
}
|
|
10690
10679
|
|
|
10691
|
-
function initPanelResize(panelId, handleId, cssVar
|
|
10680
|
+
function initPanelResize(panelId, handleId, cssVar) {
|
|
10692
10681
|
const panel = document.getElementById(panelId);
|
|
10693
10682
|
const handle = document.getElementById(handleId);
|
|
10694
10683
|
let startWidth;
|
|
@@ -10704,7 +10693,7 @@ function initPanelResize(panelId, handleId, cssVar, storageKey) {
|
|
|
10704
10693
|
},
|
|
10705
10694
|
onEnd() {
|
|
10706
10695
|
panel.classList.remove('resizing');
|
|
10707
|
-
store.setItem(
|
|
10696
|
+
store.setItem(panelWidthKey(panelId), panel.style.getPropertyValue(cssVar));
|
|
10708
10697
|
},
|
|
10709
10698
|
});
|
|
10710
10699
|
}
|
|
@@ -10714,7 +10703,7 @@ function loadPanelWidths() {
|
|
|
10714
10703
|
['detail-panel', '--detail-panel-width'],
|
|
10715
10704
|
['message-panel', '--message-panel-width'],
|
|
10716
10705
|
].forEach(([id, cssVar]) => {
|
|
10717
|
-
const w = store.getItem(
|
|
10706
|
+
const w = store.getItem(panelWidthKey(id));
|
|
10718
10707
|
if (w) document.getElementById(id).style.setProperty(cssVar, w);
|
|
10719
10708
|
});
|
|
10720
10709
|
}
|
|
@@ -10746,29 +10735,18 @@ async function showSessionInfoModal(sessionId) {
|
|
|
10746
10735
|
};
|
|
10747
10736
|
|
|
10748
10737
|
const teamPromise = session.isTeam
|
|
10749
|
-
?
|
|
10750
|
-
.then((r) => (r.ok ? r.json() : null))
|
|
10751
|
-
.catch(() => null)
|
|
10738
|
+
? getJson(apiPath`/api/teams/${session.teamName || sessionId}`)
|
|
10752
10739
|
: Promise.resolve(null);
|
|
10753
10740
|
|
|
10754
|
-
const planPromise =
|
|
10755
|
-
|
|
10756
|
-
|
|
10757
|
-
|
|
10758
|
-
if (data?.content) _pendingPlanPath = data.path || null;
|
|
10759
|
-
return data?.content || null;
|
|
10760
|
-
});
|
|
10741
|
+
const planPromise = getJson(apiPath`/api/sessions/${sessionId}/plan`).then((data) => {
|
|
10742
|
+
if (data?.content) _pendingPlanPath = data.path || null;
|
|
10743
|
+
return data?.content || null;
|
|
10744
|
+
});
|
|
10761
10745
|
|
|
10762
10746
|
const tasksPromise =
|
|
10763
|
-
cachedTasks.length > 0
|
|
10764
|
-
? Promise.resolve(cachedTasks)
|
|
10765
|
-
: fetch(`/api/sessions/${sessionId}`)
|
|
10766
|
-
.then((r) => (r.ok ? r.json() : []))
|
|
10767
|
-
.catch(() => []);
|
|
10747
|
+
cachedTasks.length > 0 ? Promise.resolve(cachedTasks) : getJson(apiPath`/api/sessions/${sessionId}`, []);
|
|
10768
10748
|
|
|
10769
|
-
const parentPromise =
|
|
10770
|
-
.then((r) => (r.ok ? r.json() : null))
|
|
10771
|
-
.catch(() => null);
|
|
10749
|
+
const parentPromise = getJson(apiPath`/api/sessions/${sessionId}/parent`);
|
|
10772
10750
|
|
|
10773
10751
|
const [teamConfig, planContent, tasks, parentInfo] = await Promise.all([
|
|
10774
10752
|
teamPromise,
|
|
@@ -10914,13 +10892,13 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
10914
10892
|
_pendingPlanContent = planContent;
|
|
10915
10893
|
const titleMatch = planContent.match(/^#\s+(.+)$/m);
|
|
10916
10894
|
const planTitle = titleMatch ? titleMatch[1].trim() : null;
|
|
10917
|
-
html += `<div data-plan-card="1" onclick="openPlanModal()"
|
|
10918
|
-
<span
|
|
10919
|
-
<div
|
|
10920
|
-
<div
|
|
10921
|
-
${planTitle ? `<div
|
|
10895
|
+
html += `<div class="plan-card" data-plan-card="1" onclick="openPlanModal()">
|
|
10896
|
+
<span class="plan-card-icon">📋</span>
|
|
10897
|
+
<div class="plan-card-text">
|
|
10898
|
+
<div class="plan-card-label">Plan</div>
|
|
10899
|
+
${planTitle ? `<div class="plan-card-title">${escapeHtml(planTitle)}</div>` : ''}
|
|
10922
10900
|
</div>
|
|
10923
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2"
|
|
10901
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2" class="plan-card-chevron"><path d="M9 18l6-6-6-6"/></svg>
|
|
10924
10902
|
</div>`;
|
|
10925
10903
|
}
|
|
10926
10904
|
|
|
@@ -10984,7 +10962,6 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
10984
10962
|
// Bind to the section, not bodyEl: bodyEl outlives the modal, so listeners on it
|
|
10985
10963
|
// would stack up once per open and fire the click handler N times.
|
|
10986
10964
|
bindLinkedDocsHandlers(bodyEl.querySelector('.linked-docs-section'), session.id);
|
|
10987
|
-
const alreadyVisible = modal.classList.contains('visible');
|
|
10988
10965
|
_infoModalSessionId = session.id;
|
|
10989
10966
|
updateStickyBtnState();
|
|
10990
10967
|
updateDismissBtnState();
|
|
@@ -10996,21 +10973,6 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
10996
10973
|
if (mkBtn) mkBtn.style.display = proj && hub.can('project.plugins') ? '' : 'none';
|
|
10997
10974
|
if (memBtn) memBtn.style.display = proj && hub.can('project.memory') ? '' : 'none';
|
|
10998
10975
|
modal.classList.add('visible');
|
|
10999
|
-
|
|
11000
|
-
if (alreadyVisible) return; // re-render during deferred hydration — key handler already attached
|
|
11001
|
-
|
|
11002
|
-
const keyHandler = (e) => {
|
|
11003
|
-
if (e.key === 'Escape') {
|
|
11004
|
-
// An inline editor inside the modal owns Escape first — it reverts itself.
|
|
11005
|
-
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
|
|
11006
|
-
if (document.getElementById('plan-modal').classList.contains('visible')) return;
|
|
11007
|
-
if (document.getElementById('tool-stats-modal').classList.contains('visible')) return;
|
|
11008
|
-
e.preventDefault();
|
|
11009
|
-
closeTeamModal();
|
|
11010
|
-
document.removeEventListener('keydown', keyHandler);
|
|
11011
|
-
}
|
|
11012
|
-
};
|
|
11013
|
-
document.addEventListener('keydown', keyHandler);
|
|
11014
10976
|
}
|
|
11015
10977
|
|
|
11016
10978
|
function closeTeamModal() {
|
|
@@ -11058,38 +11020,9 @@ let spRenderedQuery = null;
|
|
|
11058
11020
|
// Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
|
|
11059
11021
|
const spPeekCache = new Map();
|
|
11060
11022
|
|
|
11061
|
-
|
|
11062
|
-
|
|
11063
|
-
|
|
11064
|
-
let spSearchTimer = null;
|
|
11065
|
-
|
|
11066
|
-
function spSearchKey(query) {
|
|
11067
|
-
return query.length >= 3 ? query.toLowerCase() : null;
|
|
11068
|
-
}
|
|
11069
|
-
|
|
11070
|
-
function spScheduleSearch(key) {
|
|
11071
|
-
clearTimeout(spSearchTimer);
|
|
11072
|
-
spSearchTimer = setTimeout(async () => {
|
|
11073
|
-
spSearch.set(key, 'pending');
|
|
11074
|
-
let ids = [];
|
|
11075
|
-
try {
|
|
11076
|
-
const r = await fetch(`/api/sessions/search?q=${encodeURIComponent(key)}`);
|
|
11077
|
-
if (r.ok) ids = await r.json();
|
|
11078
|
-
} catch (_) {}
|
|
11079
|
-
spSearch.set(key, ids.length ? await fetchSessionsByIds(ids) : []);
|
|
11080
|
-
renderSessionPicker();
|
|
11081
|
-
}, 100);
|
|
11082
|
-
}
|
|
11083
|
-
|
|
11084
|
-
// The server rows not already matched in the sidebar list, and whether a search is still out.
|
|
11085
|
-
function spSearchRows(key, localRows) {
|
|
11086
|
-
if (!key) return { rows: [], pending: false };
|
|
11087
|
-
const found = spSearch.get(key);
|
|
11088
|
-
if (!found) spScheduleSearch(key);
|
|
11089
|
-
if (!Array.isArray(found)) return { rows: [], pending: true };
|
|
11090
|
-
const seen = new Set(localRows.map((s) => s.id));
|
|
11091
|
-
return { rows: found.filter((s) => !seen.has(s.id)), pending: false };
|
|
11092
|
-
}
|
|
11023
|
+
const spSearch = sessionSearch(() => {
|
|
11024
|
+
if (document.getElementById('session-picker-modal').classList.contains('visible')) renderSessionPicker();
|
|
11025
|
+
});
|
|
11093
11026
|
|
|
11094
11027
|
function openSessionPicker() {
|
|
11095
11028
|
spFromTerminal = terminalPaneFocused();
|
|
@@ -11097,7 +11030,6 @@ function openSessionPicker() {
|
|
|
11097
11030
|
input.value = '';
|
|
11098
11031
|
spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
|
|
11099
11032
|
spPeekCache.clear();
|
|
11100
|
-
spSearch.clear();
|
|
11101
11033
|
spRenderedQuery = null;
|
|
11102
11034
|
document.getElementById('session-picker-modal').classList.add('visible');
|
|
11103
11035
|
renderSessionPicker();
|
|
@@ -11220,15 +11152,14 @@ function renderSessionPicker() {
|
|
|
11220
11152
|
// Search rows that arrive for the same query append below, so the cursor stays on its row.
|
|
11221
11153
|
const selectedId = query === spRenderedQuery ? spRows[spIdx]?.id : null;
|
|
11222
11154
|
spRenderedQuery = query;
|
|
11223
|
-
const key = spSearchKey(query);
|
|
11224
11155
|
const local = spSource.filter((s) => spMatches(s, query));
|
|
11225
|
-
const found =
|
|
11156
|
+
const found = spSearch.rows(query, local);
|
|
11226
11157
|
spRows = [...local, ...found.rows];
|
|
11227
11158
|
if (!spRows.length) {
|
|
11228
11159
|
spIdx = -1;
|
|
11229
11160
|
const empty = found.pending
|
|
11230
11161
|
? 'Searching all sessions…'
|
|
11231
|
-
: spSource.length ||
|
|
11162
|
+
: spSource.length || query.length >= SESSION_SEARCH_MIN
|
|
11232
11163
|
? 'No session matches'
|
|
11233
11164
|
: 'No sessions in the current sidebar filter';
|
|
11234
11165
|
list.innerHTML = `<div class="sp-empty">${empty}</div>`;
|
|
@@ -11426,30 +11357,23 @@ function initProjectPicker() {
|
|
|
11426
11357
|
//#region PLAN
|
|
11427
11358
|
function refreshOpenPlan() {
|
|
11428
11359
|
if (!_planSessionId || !document.getElementById('plan-modal').classList.contains('visible')) return;
|
|
11429
|
-
|
|
11430
|
-
|
|
11431
|
-
|
|
11432
|
-
|
|
11433
|
-
|
|
11434
|
-
|
|
11435
|
-
|
|
11436
|
-
|
|
11437
|
-
|
|
11438
|
-
})
|
|
11439
|
-
.catch(() => {});
|
|
11360
|
+
getJson(apiPath`/api/sessions/${_planSessionId}/plan`).then((data) => {
|
|
11361
|
+
if (data?.content && (data.content !== _pendingPlanContent || data.path !== _pendingPlanPath)) {
|
|
11362
|
+
_pendingPlanContent = data.content;
|
|
11363
|
+
_pendingPlanPath = data.path || null;
|
|
11364
|
+
document.getElementById('plan-modal-body').innerHTML = renderMarkdown(_pendingPlanContent);
|
|
11365
|
+
mountPlanReview();
|
|
11366
|
+
syncPlanPaneBtn();
|
|
11367
|
+
}
|
|
11368
|
+
});
|
|
11440
11369
|
}
|
|
11441
11370
|
|
|
11442
11371
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
11443
11372
|
function showLoopModal(sessionId) {
|
|
11444
11373
|
const body = document.getElementById('loop-modal-body');
|
|
11445
|
-
body.innerHTML = '<div
|
|
11374
|
+
body.innerHTML = '<div class="modal-status">Loading…</div>';
|
|
11446
11375
|
document.getElementById('loop-modal').classList.add('visible');
|
|
11447
|
-
|
|
11448
|
-
.then((r) => (r.ok ? r.json() : { wakeups: [], crons: [] }))
|
|
11449
|
-
.catch(() => ({ wakeups: [], crons: [] }))
|
|
11450
|
-
.then((data) => {
|
|
11451
|
-
renderLoopModalBody(data);
|
|
11452
|
-
});
|
|
11376
|
+
getJson(apiPath`/api/sessions/${sessionId}/loop`, { wakeups: [], crons: [] }).then(renderLoopModalBody);
|
|
11453
11377
|
}
|
|
11454
11378
|
|
|
11455
11379
|
function fmtLoopDelay(s) {
|
|
@@ -11516,8 +11440,7 @@ function renderLoopModalBody(data) {
|
|
|
11516
11440
|
const wakeups = data.wakeups || [];
|
|
11517
11441
|
const crons = data.crons || [];
|
|
11518
11442
|
if (!wakeups.length && !crons.length) {
|
|
11519
|
-
body.innerHTML =
|
|
11520
|
-
'<div style="padding:24px;text-align:center;color:var(--text-secondary);">No scheduled wakeups or cron jobs.</div>';
|
|
11443
|
+
body.innerHTML = '<div class="modal-empty">No scheduled wakeups or cron jobs.</div>';
|
|
11521
11444
|
return;
|
|
11522
11445
|
}
|
|
11523
11446
|
const section = (title, items, kind) =>
|
|
@@ -11566,23 +11489,19 @@ function showWorkflowModal(sessionId) {
|
|
|
11566
11489
|
_workflowSessionId = sessionId;
|
|
11567
11490
|
setWorkflowHeader('Workflows', null);
|
|
11568
11491
|
const body = document.getElementById('workflow-modal-body');
|
|
11569
|
-
body.innerHTML = '<div
|
|
11492
|
+
body.innerHTML = '<div class="modal-status">Loading…</div>';
|
|
11570
11493
|
document.getElementById('workflow-modal').classList.add('visible');
|
|
11571
|
-
|
|
11572
|
-
|
|
11573
|
-
.
|
|
11574
|
-
|
|
11575
|
-
|
|
11576
|
-
|
|
11577
|
-
|
|
11578
|
-
|
|
11579
|
-
|
|
11580
|
-
|
|
11581
|
-
|
|
11582
|
-
} else {
|
|
11583
|
-
renderWorkflowPicker(_workflowList);
|
|
11584
|
-
}
|
|
11585
|
-
});
|
|
11494
|
+
getJson(apiPath`/api/sessions/${sessionId}/workflows`, { workflows: [] }).then((data) => {
|
|
11495
|
+
_workflowList = data.workflows || [];
|
|
11496
|
+
if (!_workflowList.length) {
|
|
11497
|
+
setWorkflowHeader('Workflows', null);
|
|
11498
|
+
body.innerHTML = '<div class="modal-empty">No workflow scripts for this session.</div>';
|
|
11499
|
+
} else if (_workflowList.length === 1) {
|
|
11500
|
+
showWorkflowRun(_workflowList[0].id);
|
|
11501
|
+
} else {
|
|
11502
|
+
renderWorkflowPicker(_workflowList);
|
|
11503
|
+
}
|
|
11504
|
+
});
|
|
11586
11505
|
}
|
|
11587
11506
|
|
|
11588
11507
|
function setWorkflowHeader(name, wfId) {
|
|
@@ -11620,17 +11539,14 @@ function showWorkflowRun(wfId) {
|
|
|
11620
11539
|
const name = _workflowList.find((w) => w.id === wfId)?.name || wfId;
|
|
11621
11540
|
setWorkflowHeader(name, wfId);
|
|
11622
11541
|
const body = document.getElementById('workflow-modal-body');
|
|
11623
|
-
body.innerHTML = '<div
|
|
11624
|
-
|
|
11625
|
-
|
|
11626
|
-
|
|
11627
|
-
|
|
11628
|
-
|
|
11629
|
-
|
|
11630
|
-
|
|
11631
|
-
}
|
|
11632
|
-
renderWorkflowRun(run);
|
|
11633
|
-
});
|
|
11542
|
+
body.innerHTML = '<div class="modal-status">Loading…</div>';
|
|
11543
|
+
getJson(apiPath`/api/sessions/${_workflowSessionId}/workflows/${wfId}/run`).then((run) => {
|
|
11544
|
+
if (!run) {
|
|
11545
|
+
body.innerHTML = '<div class="modal-status">Failed to load run state.</div>';
|
|
11546
|
+
return;
|
|
11547
|
+
}
|
|
11548
|
+
renderWorkflowRun(run);
|
|
11549
|
+
});
|
|
11634
11550
|
}
|
|
11635
11551
|
|
|
11636
11552
|
function fmtTokens(t) {
|
|
@@ -11714,27 +11630,24 @@ function renderWorkflowRun(run) {
|
|
|
11714
11630
|
|
|
11715
11631
|
function loadWorkflowCode(wfId, codeEl) {
|
|
11716
11632
|
if (!codeEl) return;
|
|
11717
|
-
codeEl.innerHTML = '<div
|
|
11718
|
-
|
|
11719
|
-
|
|
11720
|
-
|
|
11721
|
-
|
|
11722
|
-
|
|
11723
|
-
|
|
11724
|
-
|
|
11725
|
-
}
|
|
11726
|
-
|
|
11727
|
-
|
|
11728
|
-
|
|
11729
|
-
|
|
11730
|
-
|
|
11731
|
-
}
|
|
11732
|
-
codeEl.innerHTML = `<pre><code class="hljs language-javascript">${highlighted}</code></pre>`;
|
|
11733
|
-
});
|
|
11633
|
+
codeEl.innerHTML = '<div class="modal-status-tight">Loading…</div>';
|
|
11634
|
+
getJson(apiPath`/api/sessions/${_workflowSessionId}/workflows/${wfId}`).then((data) => {
|
|
11635
|
+
if (!data?.content) {
|
|
11636
|
+
codeEl.innerHTML = '<div class="modal-status-tight">Failed to load script.</div>';
|
|
11637
|
+
return;
|
|
11638
|
+
}
|
|
11639
|
+
let highlighted;
|
|
11640
|
+
if (typeof hljs !== 'undefined' && hljs.getLanguage('javascript')) {
|
|
11641
|
+
highlighted = hljs.highlight(data.content, { language: 'javascript' }).value;
|
|
11642
|
+
} else {
|
|
11643
|
+
highlighted = escapeHtml(data.content);
|
|
11644
|
+
}
|
|
11645
|
+
codeEl.innerHTML = `<pre><code class="hljs language-javascript">${highlighted}</code></pre>`;
|
|
11646
|
+
});
|
|
11734
11647
|
}
|
|
11735
11648
|
|
|
11736
11649
|
function openWorkflowInEditor(wfId) {
|
|
11737
|
-
postAndToast(`/api/sessions/${_workflowSessionId}/workflows/${
|
|
11650
|
+
postAndToast(apiPath`/api/sessions/${_workflowSessionId}/workflows/${wfId}/open`, {}, 'in editor');
|
|
11738
11651
|
}
|
|
11739
11652
|
|
|
11740
11653
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
@@ -11750,17 +11663,14 @@ function closeWorkflowModal() {
|
|
|
11750
11663
|
}
|
|
11751
11664
|
|
|
11752
11665
|
function openPlanForSession(sid) {
|
|
11753
|
-
|
|
11754
|
-
|
|
11755
|
-
|
|
11756
|
-
|
|
11757
|
-
|
|
11758
|
-
|
|
11759
|
-
|
|
11760
|
-
|
|
11761
|
-
openPlanModal();
|
|
11762
|
-
}
|
|
11763
|
-
});
|
|
11666
|
+
getJson(apiPath`/api/sessions/${sid}/plan`).then((data) => {
|
|
11667
|
+
if (data?.content) {
|
|
11668
|
+
_pendingPlanContent = data.content;
|
|
11669
|
+
_pendingPlanPath = data.path || null;
|
|
11670
|
+
_planSessionId = sid;
|
|
11671
|
+
openPlanModal();
|
|
11672
|
+
}
|
|
11673
|
+
});
|
|
11764
11674
|
}
|
|
11765
11675
|
|
|
11766
11676
|
function openPlanModal() {
|
|
@@ -11776,6 +11686,7 @@ function openPlanModal() {
|
|
|
11776
11686
|
controls.innerHTML = planApprovalControlsHtml('plan-modal-reject-feedback');
|
|
11777
11687
|
document.querySelector('#plan-modal .modal-footer').prepend(controls);
|
|
11778
11688
|
}
|
|
11689
|
+
syncPlanPaneBtn();
|
|
11779
11690
|
document.getElementById('plan-modal').classList.add('visible');
|
|
11780
11691
|
mountPlanReview();
|
|
11781
11692
|
const keyHandler = (e) => {
|
|
@@ -11810,7 +11721,7 @@ function closePlanModal() {
|
|
|
11810
11721
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
11811
11722
|
function openPlanInEditor() {
|
|
11812
11723
|
if (!_planSessionId) return;
|
|
11813
|
-
postAndToast(`/api/sessions/${_planSessionId}/plan/open`, {}, 'in editor');
|
|
11724
|
+
postAndToast(apiPath`/api/sessions/${_planSessionId}/plan/open`, {}, 'in editor');
|
|
11814
11725
|
}
|
|
11815
11726
|
|
|
11816
11727
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
@@ -11857,22 +11768,19 @@ let _toolStatsData = null;
|
|
|
11857
11768
|
function showToolStatsModal(sessionId) {
|
|
11858
11769
|
if (!sessionId) return;
|
|
11859
11770
|
const body = document.getElementById('tool-stats-modal-body');
|
|
11860
|
-
body.innerHTML = '<div
|
|
11771
|
+
body.innerHTML = '<div class="modal-status">Loading…</div>';
|
|
11861
11772
|
document.getElementById('tool-stats-modal').classList.add('visible');
|
|
11862
11773
|
|
|
11863
|
-
|
|
11864
|
-
|
|
11865
|
-
|
|
11866
|
-
|
|
11867
|
-
|
|
11868
|
-
|
|
11869
|
-
|
|
11870
|
-
|
|
11871
|
-
|
|
11872
|
-
|
|
11873
|
-
_toolStatsData = data;
|
|
11874
|
-
body.innerHTML = renderToolStatsBody(data);
|
|
11875
|
-
});
|
|
11774
|
+
getJson(apiPath`/api/sessions/${sessionId}/tool-stats`).then((data) => {
|
|
11775
|
+
if (!data) {
|
|
11776
|
+
body.innerHTML = '<div class="modal-status">Failed to load tool statistics.</div>';
|
|
11777
|
+
return;
|
|
11778
|
+
}
|
|
11779
|
+
_toolStatsSortCol = 'count';
|
|
11780
|
+
_toolStatsSortDir = 'desc';
|
|
11781
|
+
_toolStatsData = data;
|
|
11782
|
+
body.innerHTML = renderToolStatsBody(data);
|
|
11783
|
+
});
|
|
11876
11784
|
}
|
|
11877
11785
|
|
|
11878
11786
|
function renderToolStatsBody(data) {
|
|
@@ -11889,7 +11797,7 @@ function renderToolStatsBody(data) {
|
|
|
11889
11797
|
if (!tools?.length) {
|
|
11890
11798
|
return (
|
|
11891
11799
|
summary +
|
|
11892
|
-
'<div style="
|
|
11800
|
+
'<div class="modal-empty" style="color:var(--text-tertiary)">No tool calls recorded in this session.</div>'
|
|
11893
11801
|
);
|
|
11894
11802
|
}
|
|
11895
11803
|
|
|
@@ -12036,10 +11944,7 @@ function paneOwnOrigin(pane) {
|
|
|
12036
11944
|
|
|
12037
11945
|
async function probePaneFraming(sid, pane) {
|
|
12038
11946
|
const q = new URLSearchParams({ ancestors: paneAncestors().join(',') });
|
|
12039
|
-
const { frameable } = await paneRequest(
|
|
12040
|
-
'GET',
|
|
12041
|
-
`/api/panes/${encodeURIComponent(sid)}/${encodeURIComponent(pane.id)}/framing?${q}`,
|
|
12042
|
-
);
|
|
11947
|
+
const { frameable } = await paneRequest('GET', `${apiPath`/api/panes/${sid}/${pane.id}/framing`}?${q}`);
|
|
12043
11948
|
if (frameable === false) paneRefused.add(pane.target);
|
|
12044
11949
|
return frameable;
|
|
12045
11950
|
}
|
|
@@ -12063,10 +11968,8 @@ function applyPaneLayout(sid, layout) {
|
|
|
12063
11968
|
}
|
|
12064
11969
|
|
|
12065
11970
|
async function fetchPaneLayout(sid) {
|
|
12066
|
-
|
|
12067
|
-
|
|
12068
|
-
if (r.ok) applyPaneLayout(sid, await r.json());
|
|
12069
|
-
} catch {}
|
|
11971
|
+
const layout = await getJson(apiPath`/api/panes/${sid}`);
|
|
11972
|
+
if (layout) applyPaneLayout(sid, layout);
|
|
12070
11973
|
}
|
|
12071
11974
|
|
|
12072
11975
|
function refetchPaneLayout() {
|
|
@@ -12076,11 +11979,7 @@ function refetchPaneLayout() {
|
|
|
12076
11979
|
|
|
12077
11980
|
async function paneRequest(method, path, body) {
|
|
12078
11981
|
try {
|
|
12079
|
-
const r = await
|
|
12080
|
-
method,
|
|
12081
|
-
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
12082
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
12083
|
-
});
|
|
11982
|
+
const r = await api(path, { method, body });
|
|
12084
11983
|
const data = await r.json().catch(() => ({}));
|
|
12085
11984
|
return r.ok ? data : { error: data.error || `${method} failed (${r.status})`, code: data.code };
|
|
12086
11985
|
} catch {
|
|
@@ -12092,11 +11991,8 @@ async function paneRequest(method, path, body) {
|
|
|
12092
11991
|
const PANE_STATE_CAP = 20;
|
|
12093
11992
|
|
|
12094
11993
|
function loadPaneState(key) {
|
|
12095
|
-
|
|
12096
|
-
|
|
12097
|
-
if (map && typeof map === 'object') return map;
|
|
12098
|
-
} catch {}
|
|
12099
|
-
return {};
|
|
11994
|
+
const map = tabStore.readJson(key);
|
|
11995
|
+
return map && typeof map === 'object' ? map : {};
|
|
12100
11996
|
}
|
|
12101
11997
|
|
|
12102
11998
|
// Puts `sid` last, so the cap drops the least recent session. A null value removes it.
|
|
@@ -12104,14 +12000,12 @@ function savePaneState(key, map, sid, value) {
|
|
|
12104
12000
|
delete map[sid];
|
|
12105
12001
|
if (value != null) map[sid] = value;
|
|
12106
12002
|
const capped = Object.fromEntries(Object.entries(map).slice(-PANE_STATE_CAP));
|
|
12107
|
-
|
|
12108
|
-
sessionStorage.setItem(key, JSON.stringify(capped));
|
|
12109
|
-
} catch {}
|
|
12003
|
+
tabStore.writeJson(key, capped);
|
|
12110
12004
|
return capped;
|
|
12111
12005
|
}
|
|
12112
12006
|
|
|
12113
|
-
//
|
|
12114
|
-
const PANE_ACTIVE_KEY = '
|
|
12007
|
+
// {[sessionId]: paneId}.
|
|
12008
|
+
const PANE_ACTIVE_KEY = 'pane-active';
|
|
12115
12009
|
let paneActive = loadPaneState(PANE_ACTIVE_KEY);
|
|
12116
12010
|
|
|
12117
12011
|
function getActivePaneId(sid) {
|
|
@@ -12124,10 +12018,10 @@ function setActivePaneId(sid, id) {
|
|
|
12124
12018
|
notePairFocus(sid, id);
|
|
12125
12019
|
}
|
|
12126
12020
|
|
|
12127
|
-
//
|
|
12021
|
+
// {[sessionId]: [{l, r, f}]}; sides are pane ids or 'board', `f` the side focused
|
|
12128
12022
|
// last. A tab is in one pair at most, and the pair that holds the active tab is the split on
|
|
12129
12023
|
// screen. Clicks never change a pair; only the split keys and drags do.
|
|
12130
|
-
const PANE_PAIRS_KEY = '
|
|
12024
|
+
const PANE_PAIRS_KEY = 'pane-pairs';
|
|
12131
12025
|
const PANE_SPLIT_RATIO_KEY = 'pane-split-ratio';
|
|
12132
12026
|
let panePairs = loadPaneState(PANE_PAIRS_KEY);
|
|
12133
12027
|
const clampSplitRatio = (r) => Math.min(0.85, Math.max(0.15, r));
|
|
@@ -12264,6 +12158,8 @@ const PANE_ICONS = {
|
|
|
12264
12158
|
'<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 5v14"/><path d="M12 5v8"/><path d="M18 5v11"/></svg>',
|
|
12265
12159
|
url: '<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg>',
|
|
12266
12160
|
file: '<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
|
|
12161
|
+
message:
|
|
12162
|
+
'<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
|
12267
12163
|
warning:
|
|
12268
12164
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12" y2="17"/></svg>',
|
|
12269
12165
|
};
|
|
@@ -12369,9 +12265,11 @@ function markPaneSide(el, side, focused) {
|
|
|
12369
12265
|
el.classList.toggle('pane-focus', focused);
|
|
12370
12266
|
}
|
|
12371
12267
|
|
|
12268
|
+
const paneLabel = (p) => (p.kind === 'message' ? p.title : p.target);
|
|
12269
|
+
|
|
12372
12270
|
function renderPaneTabs(sid, layout, split) {
|
|
12373
|
-
// A rebuild mid-drag removes the dragged tab, and then no dragend fires.
|
|
12374
|
-
if (paneDragId) return;
|
|
12271
|
+
// A rebuild mid-drag removes the dragged tab, and then no dragend fires. Mid-rename it drops the input.
|
|
12272
|
+
if (paneDragId || paneRenameId) return;
|
|
12375
12273
|
const active = getActivePaneId(sid);
|
|
12376
12274
|
const state = (id) => (active === id ? ' on' : split && splitSideOf(split, id) ? ' shown' : '');
|
|
12377
12275
|
const tab = (id, cls, inner, tip) =>
|
|
@@ -12392,8 +12290,8 @@ function renderPaneTabs(sid, layout, split) {
|
|
|
12392
12290
|
return tab(
|
|
12393
12291
|
p.id,
|
|
12394
12292
|
'',
|
|
12395
|
-
`${p.kind
|
|
12396
|
-
p
|
|
12293
|
+
`${PANE_ICONS[p.kind] || PANE_ICONS.file}<span class="pane-title">${escapeHtml(p.title)}</span><span class="state ${escapeHtml(frame)}" title="${escapeHtml(frameTip)}"></span><button class="pane-x" data-close="${escapeHtml(p.id)}" title="Close pane (Alt+W)" aria-label="Close pane">×</button>`,
|
|
12294
|
+
paneLabel(p),
|
|
12397
12295
|
);
|
|
12398
12296
|
};
|
|
12399
12297
|
const itemHtml = ({ ids, pair }) =>
|
|
@@ -12433,7 +12331,8 @@ function markPaneFrameFocus() {
|
|
|
12433
12331
|
}
|
|
12434
12332
|
|
|
12435
12333
|
function paneCardHtml(title, text, button) {
|
|
12436
|
-
|
|
12334
|
+
const btn = button ? `<button class="btn btn-primary" data-pane-open>${escapeHtml(button)}</button>` : '';
|
|
12335
|
+
return `<div class="pane-card"><div class="pane-card-box">${PANE_ICONS.warning}<h3>${escapeHtml(title)}</h3><p>${escapeHtml(text)}</p>${btn}</div></div>`;
|
|
12437
12336
|
}
|
|
12438
12337
|
|
|
12439
12338
|
// Returns the key of the view to show.
|
|
@@ -12496,11 +12395,11 @@ async function loadPaneView(sid, pane, view, scroll) {
|
|
|
12496
12395
|
}
|
|
12497
12396
|
return;
|
|
12498
12397
|
}
|
|
12499
|
-
|
|
12500
|
-
|
|
12501
|
-
|
|
12502
|
-
|
|
12503
|
-
|
|
12398
|
+
if (pane.kind === 'message') {
|
|
12399
|
+
await loadMessagePaneView(sid, pane, view);
|
|
12400
|
+
return;
|
|
12401
|
+
}
|
|
12402
|
+
const data = await getJson('/api/preview', null, { query: { path: pane.target } });
|
|
12504
12403
|
if (data?.kind === 'html') {
|
|
12505
12404
|
// The frame's origin is opaque, so the page restores its own scroll on load.
|
|
12506
12405
|
const restore = scroll
|
|
@@ -12513,14 +12412,9 @@ async function loadPaneView(sid, pane, view, scroll) {
|
|
|
12513
12412
|
return;
|
|
12514
12413
|
}
|
|
12515
12414
|
if (data?.kind) {
|
|
12516
|
-
const
|
|
12517
|
-
|
|
12518
|
-
doc.dataset.path = data.path;
|
|
12519
|
-
const body = document.createElement('div');
|
|
12520
|
-
body.className = 'pane-doc-body rendered-md modal-zoomable';
|
|
12415
|
+
const body = appendPaneDoc(view);
|
|
12416
|
+
body.parentElement.dataset.path = data.path;
|
|
12521
12417
|
renderPreviewContent(body, data.path, data.content, data.kind);
|
|
12522
|
-
doc.append(body, paneDocGrip(doc));
|
|
12523
|
-
view.appendChild(doc);
|
|
12524
12418
|
attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, body, null));
|
|
12525
12419
|
return;
|
|
12526
12420
|
}
|
|
@@ -12533,6 +12427,49 @@ async function loadPaneView(sid, pane, view, scroll) {
|
|
|
12533
12427
|
view.insertAdjacentHTML('beforeend', paneCardHtml(title, text, button));
|
|
12534
12428
|
}
|
|
12535
12429
|
|
|
12430
|
+
// The target is the message's pin id, `type|timestamp|text`. The log holds only the latest pages,
|
|
12431
|
+
// so a message not loaded comes from the page that ends at its timestamp.
|
|
12432
|
+
async function findPaneMessage(sid, target) {
|
|
12433
|
+
const own = (list) => list.find((m) => getPinId(m) === target);
|
|
12434
|
+
if (sid === currentSessionId && !agentLogMode) {
|
|
12435
|
+
const loaded = own(currentMessages);
|
|
12436
|
+
if (loaded) return loaded;
|
|
12437
|
+
}
|
|
12438
|
+
const ts = Date.parse(target.split('|')[1]);
|
|
12439
|
+
if (Number.isFinite(ts)) {
|
|
12440
|
+
try {
|
|
12441
|
+
// Parallel tool calls share one timestamp, so the page is wider than one message.
|
|
12442
|
+
const page = await fetchSessionMessagesPage(sid, { before: new Date(ts + 1).toISOString(), limit: 50 });
|
|
12443
|
+
const found = page && own(page.messages);
|
|
12444
|
+
if (found) return found;
|
|
12445
|
+
} catch {}
|
|
12446
|
+
}
|
|
12447
|
+
return sid === currentSessionId ? own(currentPins) || null : null;
|
|
12448
|
+
}
|
|
12449
|
+
|
|
12450
|
+
function appendPaneDoc(view) {
|
|
12451
|
+
const doc = document.createElement('div');
|
|
12452
|
+
doc.className = 'pane-doc';
|
|
12453
|
+
const body = document.createElement('div');
|
|
12454
|
+
body.className = 'pane-doc-body rendered-md modal-zoomable';
|
|
12455
|
+
doc.append(body, paneDocGrip(doc));
|
|
12456
|
+
view.appendChild(doc);
|
|
12457
|
+
return body;
|
|
12458
|
+
}
|
|
12459
|
+
|
|
12460
|
+
async function loadMessagePaneView(sid, pane, view) {
|
|
12461
|
+
const m = await findPaneMessage(sid, pane.target);
|
|
12462
|
+
if (!m) {
|
|
12463
|
+
view.insertAdjacentHTML(
|
|
12464
|
+
'beforeend',
|
|
12465
|
+
paneCardHtml('Message not found', 'The session log no longer has this message.'),
|
|
12466
|
+
);
|
|
12467
|
+
return;
|
|
12468
|
+
}
|
|
12469
|
+
const meta = [msgDetailTitle(m), ...msgDetailMeta(m)].join(' · ');
|
|
12470
|
+
appendPaneDoc(view).innerHTML = `<div class="pane-msg-meta">${escapeHtml(meta)}</div>${msgDetailContent(m)}`;
|
|
12471
|
+
}
|
|
12472
|
+
|
|
12536
12473
|
function onPaneFileChanged(filePath) {
|
|
12537
12474
|
for (const [key, view] of paneFrames) {
|
|
12538
12475
|
const [sid, paneId] = key.split('/');
|
|
@@ -12689,9 +12626,25 @@ function cyclePaneStrip(step) {
|
|
|
12689
12626
|
return true;
|
|
12690
12627
|
}
|
|
12691
12628
|
|
|
12692
|
-
function
|
|
12629
|
+
function isMenuKey(e) {
|
|
12630
|
+
return e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey);
|
|
12631
|
+
}
|
|
12632
|
+
|
|
12633
|
+
function focusedPaneId() {
|
|
12693
12634
|
const sid = paneSessionId();
|
|
12694
|
-
|
|
12635
|
+
return sid && !wantsTerminal() && getActivePaneId(sid);
|
|
12636
|
+
}
|
|
12637
|
+
|
|
12638
|
+
function openFocusedPaneMenu() {
|
|
12639
|
+
const id = focusedPaneId();
|
|
12640
|
+
const tab = id && paneStrip.querySelector(`.pane-tab[data-pane="${CSS.escape(id)}"]`);
|
|
12641
|
+
if (!tab?.offsetParent) return false;
|
|
12642
|
+
const rect = tab.getBoundingClientRect();
|
|
12643
|
+
return openPaneMenu(rect.left + 16, rect.bottom, id);
|
|
12644
|
+
}
|
|
12645
|
+
|
|
12646
|
+
function closeFocusedPane() {
|
|
12647
|
+
const id = focusedPaneId();
|
|
12695
12648
|
if (!id || id === 'board') return false;
|
|
12696
12649
|
closePane(id);
|
|
12697
12650
|
return true;
|
|
@@ -12713,7 +12666,7 @@ async function closePane(paneId) {
|
|
|
12713
12666
|
}
|
|
12714
12667
|
unmountPane(sid, paneId);
|
|
12715
12668
|
syncPanes();
|
|
12716
|
-
const res = await paneRequest('DELETE', `/api/panes/${
|
|
12669
|
+
const res = await paneRequest('DELETE', apiPath`/api/panes/${sid}/${paneId}`);
|
|
12717
12670
|
if (res.layout) applyPaneLayout(sid, res.layout);
|
|
12718
12671
|
else if (res.code === 'no_pane') fetchPaneLayout(sid);
|
|
12719
12672
|
else showToast(res.error, 'error');
|
|
@@ -12743,21 +12696,39 @@ function reloadPane(id) {
|
|
|
12743
12696
|
function openPaneExternally(id) {
|
|
12744
12697
|
const pane = paneById(id);
|
|
12745
12698
|
if (!pane) return;
|
|
12699
|
+
if (pane.kind === 'message') {
|
|
12700
|
+
openPaneMessageDetail(pane);
|
|
12701
|
+
return;
|
|
12702
|
+
}
|
|
12746
12703
|
openLinkedDoc(pane.target, getSessionBaseDir(paneSessionId()));
|
|
12747
12704
|
}
|
|
12748
12705
|
|
|
12706
|
+
async function openPaneMessageDetail(pane) {
|
|
12707
|
+
const sid = paneSessionId();
|
|
12708
|
+
const m = await findPaneMessage(sid, pane.target);
|
|
12709
|
+
if (sid !== paneSessionId()) return;
|
|
12710
|
+
if (!m) {
|
|
12711
|
+
showToast('Message not found');
|
|
12712
|
+
return;
|
|
12713
|
+
}
|
|
12714
|
+
const idx = currentMessages.indexOf(m);
|
|
12715
|
+
if (idx >= 0) showMsgDetail(idx);
|
|
12716
|
+
else showUnloadedMsgDetail(m);
|
|
12717
|
+
}
|
|
12718
|
+
|
|
12749
12719
|
function copyPaneTarget(id) {
|
|
12750
12720
|
const pane = paneById(id);
|
|
12751
12721
|
if (!pane) return;
|
|
12752
|
-
|
|
12753
|
-
() => showToast(pane.kind === 'url' ? 'URL copied' : 'Path copied', 'success'),
|
|
12754
|
-
() => showToast('Copy failed', 'error'),
|
|
12755
|
-
);
|
|
12722
|
+
copyText(pane.target, pane.kind === 'url' ? 'URL' : 'path');
|
|
12756
12723
|
}
|
|
12757
12724
|
|
|
12758
|
-
function closePaneMenu() {
|
|
12725
|
+
function closePaneMenu(restore) {
|
|
12759
12726
|
document.removeEventListener('keydown', paneMenuKeydown, true);
|
|
12760
|
-
document.getElementById('pane-menu')
|
|
12727
|
+
const menu = document.getElementById('pane-menu');
|
|
12728
|
+
if (menu) {
|
|
12729
|
+
menu.remove();
|
|
12730
|
+
takeMenuFocus(restore);
|
|
12731
|
+
}
|
|
12761
12732
|
}
|
|
12762
12733
|
|
|
12763
12734
|
function paneMenuKeydown(e) {
|
|
@@ -12782,7 +12753,7 @@ async function deleteBoardTasks(onlyCompleted) {
|
|
|
12782
12753
|
});
|
|
12783
12754
|
if (!ok) return;
|
|
12784
12755
|
const q = onlyCompleted ? '?status=completed' : '';
|
|
12785
|
-
const res = await paneRequest('DELETE', `/api/tasks/${
|
|
12756
|
+
const res = await paneRequest('DELETE', `${apiPath`/api/tasks/${sid}`}${q}`);
|
|
12786
12757
|
if (res.error) {
|
|
12787
12758
|
showToast(`Failed to delete tasks: ${res.error}`, 'error');
|
|
12788
12759
|
return;
|
|
@@ -12792,6 +12763,42 @@ async function deleteBoardTasks(onlyCompleted) {
|
|
|
12792
12763
|
await refreshCurrentView();
|
|
12793
12764
|
}
|
|
12794
12765
|
|
|
12766
|
+
let paneRenameId = null;
|
|
12767
|
+
|
|
12768
|
+
function beginPaneRename(id) {
|
|
12769
|
+
const pane = paneById(id);
|
|
12770
|
+
const tab = paneStrip.querySelector(`.pane-tab[data-pane="${CSS.escape(id)}"]`);
|
|
12771
|
+
const title = tab?.querySelector('.pane-title');
|
|
12772
|
+
if (!pane || !title) return;
|
|
12773
|
+
paneRenameId = id;
|
|
12774
|
+
tab.draggable = false;
|
|
12775
|
+
const input = document.createElement('input');
|
|
12776
|
+
input.className = 'pane-title-input';
|
|
12777
|
+
input.value = pane.title;
|
|
12778
|
+
input.setAttribute('aria-label', 'Pane title');
|
|
12779
|
+
title.replaceWith(input);
|
|
12780
|
+
input.focus();
|
|
12781
|
+
input.select();
|
|
12782
|
+
}
|
|
12783
|
+
|
|
12784
|
+
async function endPaneRename(input, save) {
|
|
12785
|
+
const id = paneRenameId;
|
|
12786
|
+
if (!id) return;
|
|
12787
|
+
paneRenameId = null;
|
|
12788
|
+
paneTabsHtml = '';
|
|
12789
|
+
const sid = paneSessionId();
|
|
12790
|
+
const pane = paneById(id);
|
|
12791
|
+
const title = input.value.trim();
|
|
12792
|
+
if (save && sid && pane && title !== pane.title) {
|
|
12793
|
+
const res = await paneRequest('PATCH', apiPath`/api/panes/${sid}/${id}`, {
|
|
12794
|
+
title,
|
|
12795
|
+
});
|
|
12796
|
+
if (res.error) showToast(res.error, 'error');
|
|
12797
|
+
else applyPaneLayout(sid, res.layout);
|
|
12798
|
+
}
|
|
12799
|
+
syncPanes();
|
|
12800
|
+
}
|
|
12801
|
+
|
|
12795
12802
|
const paneMenuItem = (act, label) =>
|
|
12796
12803
|
`<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}">${label}</button>`;
|
|
12797
12804
|
|
|
@@ -12806,22 +12813,26 @@ function openPaneMenu(x, y, id) {
|
|
|
12806
12813
|
paneMenuItem('deleteCompleted', 'Delete completed tasks') +
|
|
12807
12814
|
paneMenuItem('deleteAll', 'Delete all tasks'),
|
|
12808
12815
|
);
|
|
12809
|
-
return;
|
|
12816
|
+
return true;
|
|
12810
12817
|
}
|
|
12811
12818
|
const pane = paneById(id);
|
|
12812
|
-
if (!pane) return;
|
|
12819
|
+
if (!pane) return false;
|
|
12813
12820
|
const url = pane.kind === 'url';
|
|
12821
|
+
const message = pane.kind === 'message';
|
|
12822
|
+
const label = paneLabel(pane);
|
|
12814
12823
|
showPaneMenu(
|
|
12815
12824
|
x,
|
|
12816
12825
|
y,
|
|
12817
12826
|
pane.id,
|
|
12818
12827
|
'Pane actions',
|
|
12819
|
-
`<div class="pane-menu-label" title="${escapeHtml(
|
|
12820
|
-
paneMenuItem('copy', url ? 'Copy URL' : 'Copy path') +
|
|
12821
|
-
paneMenuItem('open', url ? 'Open in new tab' : 'Open in preview') +
|
|
12828
|
+
`<div class="pane-menu-label" title="${escapeHtml(label)}">${escapeHtml(label)}</div>` +
|
|
12829
|
+
(message ? '' : paneMenuItem('copy', url ? 'Copy URL' : 'Copy path')) +
|
|
12830
|
+
paneMenuItem('open', url ? 'Open in new tab' : message ? 'Open in message dialog' : 'Open in preview') +
|
|
12831
|
+
paneMenuItem('rename', 'Rename') +
|
|
12822
12832
|
paneMenuItem('reload', 'Reload') +
|
|
12823
12833
|
paneMenuItem('close', 'Close pane'),
|
|
12824
12834
|
);
|
|
12835
|
+
return true;
|
|
12825
12836
|
}
|
|
12826
12837
|
|
|
12827
12838
|
function showPaneMenu(x, y, id, ariaLabel, html) {
|
|
@@ -12842,6 +12853,7 @@ function showPaneMenu(x, y, id, ariaLabel, html) {
|
|
|
12842
12853
|
const PANE_MENU_ACTIONS = {
|
|
12843
12854
|
copy: copyPaneTarget,
|
|
12844
12855
|
open: openPaneExternally,
|
|
12856
|
+
rename: beginPaneRename,
|
|
12845
12857
|
reload: reloadPane,
|
|
12846
12858
|
close: closePane,
|
|
12847
12859
|
deleteCompleted: () => deleteBoardTasks(true),
|
|
@@ -12864,32 +12876,81 @@ async function addPane(raw) {
|
|
|
12864
12876
|
const t = sid && paneTargetFrom(raw);
|
|
12865
12877
|
if (!t) return;
|
|
12866
12878
|
if (t.error) {
|
|
12867
|
-
showToast(t.error);
|
|
12879
|
+
showToast(t.error, 'error');
|
|
12868
12880
|
return;
|
|
12869
12881
|
}
|
|
12870
|
-
|
|
12871
|
-
|
|
12872
|
-
|
|
12873
|
-
|
|
12882
|
+
if (!(await postPane(sid, { target: t.target, base: getSessionBaseDir(sid) || undefined }))) return;
|
|
12883
|
+
closePanePop();
|
|
12884
|
+
syncPanes();
|
|
12885
|
+
}
|
|
12886
|
+
|
|
12887
|
+
// Shows the added pane. True when its session is still on screen, null when the add failed.
|
|
12888
|
+
async function postPane(sid, body) {
|
|
12889
|
+
const res = await paneRequest('POST', apiPath`/api/panes/${sid}`, body);
|
|
12874
12890
|
if (!res.pane) {
|
|
12875
12891
|
showToast(res.error, 'error');
|
|
12876
|
-
return;
|
|
12892
|
+
return null;
|
|
12877
12893
|
}
|
|
12878
12894
|
setActivePaneId(sid, res.pane.id);
|
|
12879
12895
|
applyPaneLayout(sid, res.layout);
|
|
12880
|
-
|
|
12881
|
-
closePanePop();
|
|
12882
|
-
syncPanes();
|
|
12896
|
+
return sid === paneSessionId();
|
|
12883
12897
|
}
|
|
12884
12898
|
|
|
12885
|
-
|
|
12886
|
-
function openPreviewInPane() {
|
|
12887
|
-
const filePath = currentPreviewPath;
|
|
12899
|
+
function openFileInPane(filePath, closeModal) {
|
|
12888
12900
|
if (!filePath || !paneSessionId()) return;
|
|
12889
|
-
|
|
12901
|
+
closeModal();
|
|
12890
12902
|
addPane(filePath);
|
|
12891
12903
|
}
|
|
12892
12904
|
|
|
12905
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
12906
|
+
function openPreviewInPane() {
|
|
12907
|
+
openFileInPane(currentPreviewPath, closePreviewModal);
|
|
12908
|
+
}
|
|
12909
|
+
|
|
12910
|
+
// The plan modal also opens other sessions' plans; the pane goes to the plan's session, wherever it is.
|
|
12911
|
+
const planPaneAllowed = () => !!_pendingPlanPath && !!_planSessionId;
|
|
12912
|
+
|
|
12913
|
+
function syncPlanPaneBtn() {
|
|
12914
|
+
document.getElementById('plan-pane-btn').style.display = planPaneAllowed() ? '' : 'none';
|
|
12915
|
+
}
|
|
12916
|
+
|
|
12917
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
12918
|
+
async function openPlanInPane() {
|
|
12919
|
+
if (!planPaneAllowed()) return;
|
|
12920
|
+
const sid = _planSessionId;
|
|
12921
|
+
const target = _pendingPlanPath;
|
|
12922
|
+
closePlanModal();
|
|
12923
|
+
if (sid === paneSessionId()) return addPane(target);
|
|
12924
|
+
if ((await postPane(sid, { target, base: getSessionBaseDir(sid) || undefined })) !== false) return;
|
|
12925
|
+
const s = sessions.find((x) => x.id === sid);
|
|
12926
|
+
showToast(`Plan added to ${s ? sessionDisplayName(s) : 'its session'}`, 'success', {
|
|
12927
|
+
label: 'Open',
|
|
12928
|
+
onClick: () => openSession(sid),
|
|
12929
|
+
});
|
|
12930
|
+
}
|
|
12931
|
+
|
|
12932
|
+
function msgDetailPaneSource() {
|
|
12933
|
+
if (agentLogMode || !paneSessionId()) return null;
|
|
12934
|
+
if (currentMsgDetailIdx != null) return currentMessages[currentMsgDetailIdx] || null;
|
|
12935
|
+
const pin = currentPinDetailId && currentPins.find((p) => p.id === currentPinDetailId);
|
|
12936
|
+
return pin && pin.type !== 'agent' ? pin : null;
|
|
12937
|
+
}
|
|
12938
|
+
|
|
12939
|
+
function syncMsgDetailPaneBtn() {
|
|
12940
|
+
document.getElementById('msg-detail-pane-btn').style.display = msgDetailPaneSource()?.timestamp ? '' : 'none';
|
|
12941
|
+
}
|
|
12942
|
+
|
|
12943
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
12944
|
+
async function openMsgInPane() {
|
|
12945
|
+
const m = msgDetailPaneSource();
|
|
12946
|
+
if (!m?.timestamp) return;
|
|
12947
|
+
const sid = paneSessionId();
|
|
12948
|
+
const d = new Date(m.timestamp);
|
|
12949
|
+
const title = `${msgDetailTitle(m) || 'Message'} ${d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
|
|
12950
|
+
closeMsgDetailModal();
|
|
12951
|
+
if (await postPane(sid, { kind: 'message', target: getPinId(m), title })) syncPanes();
|
|
12952
|
+
}
|
|
12953
|
+
|
|
12893
12954
|
let panePopMatches = [];
|
|
12894
12955
|
// -1 means Enter adds the typed value.
|
|
12895
12956
|
let panePopIdx = -1;
|
|
@@ -12942,7 +13003,19 @@ function closePanePop() {
|
|
|
12942
13003
|
panePop.classList.remove('visible');
|
|
12943
13004
|
}
|
|
12944
13005
|
|
|
13006
|
+
paneStrip.addEventListener('keydown', (e) => {
|
|
13007
|
+
const input = e.target.closest?.('.pane-title-input');
|
|
13008
|
+
if (input && (e.key === 'Enter' || e.key === 'Escape')) {
|
|
13009
|
+
e.preventDefault();
|
|
13010
|
+
endPaneRename(input, e.key === 'Enter');
|
|
13011
|
+
}
|
|
13012
|
+
});
|
|
13013
|
+
paneStrip.addEventListener('focusout', (e) => {
|
|
13014
|
+
const input = e.target.closest?.('.pane-title-input');
|
|
13015
|
+
if (input) endPaneRename(input, true);
|
|
13016
|
+
});
|
|
12945
13017
|
paneStrip.addEventListener('click', (e) => {
|
|
13018
|
+
if (e.target.closest('.pane-title-input')) return;
|
|
12946
13019
|
const close = e.target.closest('[data-close]');
|
|
12947
13020
|
if (close) {
|
|
12948
13021
|
closePane(close.dataset.close);
|
|
@@ -12966,7 +13039,8 @@ document.addEventListener('click', (e) => {
|
|
|
12966
13039
|
if (!menu) return;
|
|
12967
13040
|
const act = e.target.closest('.pane-menu-item')?.dataset.paneAct;
|
|
12968
13041
|
if (act) PANE_MENU_ACTIONS[act](menu.dataset.pane);
|
|
12969
|
-
if (act
|
|
13042
|
+
if (act) closePaneMenu();
|
|
13043
|
+
else if (!menu.contains(e.target)) closePaneMenu(true);
|
|
12970
13044
|
});
|
|
12971
13045
|
function focusSideOf(el) {
|
|
12972
13046
|
if (el?.classList.contains('side-l')) focusPaneSide('l');
|
|
@@ -13022,18 +13096,13 @@ paneSplitBox.addEventListener(
|
|
|
13022
13096
|
function endPaneDrag() {
|
|
13023
13097
|
if (!paneDragId) return;
|
|
13024
13098
|
paneDragId = null;
|
|
13099
|
+
markPaneDrop(null);
|
|
13025
13100
|
paneSplitBox.classList.remove('dropping');
|
|
13026
|
-
paneStrip.classList.remove('drop-hot');
|
|
13027
13101
|
for (const el of paneDrop.children) el.classList.remove('hot');
|
|
13028
13102
|
paneStrip.querySelector('.pane-tab.dragging')?.classList.remove('dragging');
|
|
13029
13103
|
syncPanes();
|
|
13030
13104
|
}
|
|
13031
13105
|
|
|
13032
|
-
function draggedPair() {
|
|
13033
|
-
const sid = paneSessionId();
|
|
13034
|
-
return (sid && paneDragId && pairOf(sid, paneDragId)) || null;
|
|
13035
|
-
}
|
|
13036
|
-
|
|
13037
13106
|
paneStrip.addEventListener('dragstart', (e) => {
|
|
13038
13107
|
const tab = e.target.closest('.pane-tab');
|
|
13039
13108
|
if (!tab || !paneSessionId() || wantsTerminal()) return;
|
|
@@ -13043,24 +13112,64 @@ paneStrip.addEventListener('dragstart', (e) => {
|
|
|
13043
13112
|
tab.classList.add('dragging');
|
|
13044
13113
|
paneSplitBox.classList.add('dropping');
|
|
13045
13114
|
});
|
|
13115
|
+
|
|
13116
|
+
// A split shows as one strip item, so a drop moves the dragged tab's whole item; the first item
|
|
13117
|
+
// holds the board and stays put. Elsewhere on the strip, a split tab's drop ends its split.
|
|
13118
|
+
function paneDropAction(e) {
|
|
13119
|
+
const sid = paneSessionId();
|
|
13120
|
+
const tab = e.target.closest?.('.pane-tab');
|
|
13121
|
+
if (!sid || !paneDragId) return null;
|
|
13122
|
+
if (tab) {
|
|
13123
|
+
const [fixed, ...movable] = paneStripItems(sid);
|
|
13124
|
+
const itemOf = (id) => movable.find((it) => it.ids.includes(id));
|
|
13125
|
+
const from = itemOf(paneDragId);
|
|
13126
|
+
const to = itemOf(tab.dataset.pane);
|
|
13127
|
+
if (from && to && from !== to) {
|
|
13128
|
+
const el = to.pair ? tab.closest('.pane-pair') : tab;
|
|
13129
|
+
const box = el.getBoundingClientRect();
|
|
13130
|
+
return { kind: 'move', sid, el, fixed, movable, from, to, after: e.clientX > box.left + box.width / 2 };
|
|
13131
|
+
}
|
|
13132
|
+
}
|
|
13133
|
+
const pair = pairOf(sid, paneDragId);
|
|
13134
|
+
return pair ? { kind: 'unpair', sid, pair } : null;
|
|
13135
|
+
}
|
|
13136
|
+
|
|
13137
|
+
function markPaneDrop(action) {
|
|
13138
|
+
paneStrip.classList.toggle('drop-hot', action?.kind === 'unpair');
|
|
13139
|
+
for (const el of paneStrip.querySelectorAll('.pane-tab, .pane-pair')) {
|
|
13140
|
+
el.classList.toggle('drop-before', el === action?.el && !action.after);
|
|
13141
|
+
el.classList.toggle('drop-after', el === action?.el && action.after);
|
|
13142
|
+
}
|
|
13143
|
+
}
|
|
13144
|
+
|
|
13145
|
+
async function movePaneItem({ sid, fixed, movable, from, to, after }) {
|
|
13146
|
+
const items = movable.filter((it) => it !== from);
|
|
13147
|
+
items.splice(items.indexOf(to) + (after ? 1 : 0), 0, from);
|
|
13148
|
+
const order = [fixed, ...items].flatMap((it) => it.ids).filter((id) => id !== 'board');
|
|
13149
|
+
const res = await paneRequest('PATCH', apiPath`/api/panes/${sid}`, { order });
|
|
13150
|
+
if (res.error) showToast(res.error, 'error');
|
|
13151
|
+
else applyPaneLayout(sid, res.layout);
|
|
13152
|
+
}
|
|
13153
|
+
|
|
13046
13154
|
paneStrip.addEventListener('dragend', endPaneDrag);
|
|
13047
13155
|
paneStrip.addEventListener('dragover', (e) => {
|
|
13048
|
-
|
|
13049
|
-
|
|
13050
|
-
|
|
13156
|
+
const action = paneDropAction(e);
|
|
13157
|
+
markPaneDrop(action);
|
|
13158
|
+
if (action) e.preventDefault();
|
|
13051
13159
|
});
|
|
13052
13160
|
paneStrip.addEventListener('dragleave', (e) => {
|
|
13053
|
-
if (!paneStrip.contains(e.relatedTarget))
|
|
13161
|
+
if (!paneStrip.contains(e.relatedTarget)) markPaneDrop(null);
|
|
13054
13162
|
});
|
|
13055
13163
|
paneStrip.addEventListener('drop', (e) => {
|
|
13056
|
-
const
|
|
13057
|
-
if (!
|
|
13164
|
+
const action = paneDropAction(e);
|
|
13165
|
+
if (!action) return;
|
|
13058
13166
|
e.preventDefault();
|
|
13059
|
-
const sid =
|
|
13060
|
-
const shown = !!splitSideOf(pair, getActivePaneId(sid));
|
|
13167
|
+
const { sid, pair } = action;
|
|
13168
|
+
const shown = pair && !!splitSideOf(pair, getActivePaneId(sid));
|
|
13061
13169
|
const keep = shown ? pair[otherSide(splitSideOf(pair, paneDragId))] : null;
|
|
13062
13170
|
endPaneDrag();
|
|
13063
|
-
|
|
13171
|
+
if (action.kind === 'move') movePaneItem(action);
|
|
13172
|
+
else unpairPanes(sid, pair, keep);
|
|
13064
13173
|
});
|
|
13065
13174
|
paneDrop.addEventListener('dragover', (e) => {
|
|
13066
13175
|
if (!paneDragId) return;
|
|
@@ -13177,8 +13286,7 @@ panePop.addEventListener('keydown', (e) => {
|
|
|
13177
13286
|
// leaving the session view or switching sessions drops only the socket; coming back
|
|
13178
13287
|
// reattaches and the server replays the screen.
|
|
13179
13288
|
const TERMINAL_TOKEN_KEY = 'terminal-token';
|
|
13180
|
-
|
|
13181
|
-
const TERMINAL_FOCUS_KEY = nsKey('terminal-focus');
|
|
13289
|
+
const TERMINAL_FOCUS_KEY = 'terminal-focus';
|
|
13182
13290
|
const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
13183
13291
|
const TERMINAL_MODES_KEY = 'terminal-sessions';
|
|
13184
13292
|
const TERMINAL_FONT_KEY = 'terminal-font-size';
|
|
@@ -13231,17 +13339,11 @@ function readTerminalToken() {
|
|
|
13231
13339
|
history.replaceState(null, '', location.pathname + location.search);
|
|
13232
13340
|
return m[1];
|
|
13233
13341
|
}
|
|
13234
|
-
|
|
13235
|
-
return sessionStorage.getItem(TERMINAL_TOKEN_KEY);
|
|
13236
|
-
} catch (_) {
|
|
13237
|
-
return null;
|
|
13238
|
-
}
|
|
13342
|
+
return tabStore.getItem(TERMINAL_TOKEN_KEY);
|
|
13239
13343
|
}
|
|
13240
13344
|
|
|
13241
13345
|
function storeTerminalToken(token) {
|
|
13242
|
-
|
|
13243
|
-
sessionStorage.setItem(TERMINAL_TOKEN_KEY, token);
|
|
13244
|
-
} catch (_) {}
|
|
13346
|
+
tabStore.setItem(TERMINAL_TOKEN_KEY, token);
|
|
13245
13347
|
}
|
|
13246
13348
|
|
|
13247
13349
|
let tokenRefresh = null;
|
|
@@ -13275,9 +13377,7 @@ function setTerminalMode(sessionId, on) {
|
|
|
13275
13377
|
const modes = terminalModes();
|
|
13276
13378
|
if (on) modes.add(sessionId);
|
|
13277
13379
|
else modes.delete(sessionId);
|
|
13278
|
-
|
|
13279
|
-
store.setItem(TERMINAL_MODES_KEY, JSON.stringify([...modes].slice(-50)));
|
|
13280
|
-
} catch (_) {}
|
|
13380
|
+
store.writeJson(TERMINAL_MODES_KEY, [...modes].slice(-50));
|
|
13281
13381
|
}
|
|
13282
13382
|
|
|
13283
13383
|
function wantsTerminal() {
|
|
@@ -13340,19 +13440,14 @@ function terminalShortcut(e, probe = false) {
|
|
|
13340
13440
|
}
|
|
13341
13441
|
|
|
13342
13442
|
function readSwapPair() {
|
|
13343
|
-
|
|
13344
|
-
|
|
13345
|
-
if (Array.isArray(pair) && pair.length === 2) return pair;
|
|
13346
|
-
} catch {}
|
|
13347
|
-
return [null, null];
|
|
13443
|
+
const pair = readStoredList(SWAP_KEY);
|
|
13444
|
+
return pair.length === 2 ? pair : [null, null];
|
|
13348
13445
|
}
|
|
13349
13446
|
|
|
13350
13447
|
function setSwapPair(last, previous) {
|
|
13351
13448
|
lastSessionId = last;
|
|
13352
13449
|
previousSessionId = previous;
|
|
13353
|
-
|
|
13354
|
-
store.setItem(SWAP_KEY, JSON.stringify([last, previous]));
|
|
13355
|
-
} catch {}
|
|
13450
|
+
store.writeJson(SWAP_KEY, [last, previous]);
|
|
13356
13451
|
}
|
|
13357
13452
|
|
|
13358
13453
|
async function swapToPreviousSession() {
|
|
@@ -13420,8 +13515,10 @@ function syncTerminal() {
|
|
|
13420
13515
|
}
|
|
13421
13516
|
|
|
13422
13517
|
// Only paths the user drives call this; restores, deep links and refreshes call fetchTasks, so they never take focus.
|
|
13423
|
-
function openSession(sessionId) {
|
|
13518
|
+
async function openSession(sessionId) {
|
|
13424
13519
|
setSessionDismissed(sessionId, false);
|
|
13520
|
+
// Awaited only when missing, so opening a loaded session stays synchronous up to fetchTasks.
|
|
13521
|
+
if (!sessions.some((s) => s.id === sessionId)) await ensureSessionLoaded(sessionId);
|
|
13425
13522
|
const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
|
|
13426
13523
|
if (!alreadyOpen) termState.openedByUser = sessionId;
|
|
13427
13524
|
else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
|
|
@@ -13847,18 +13944,13 @@ window.addEventListener('focus', () => {
|
|
|
13847
13944
|
});
|
|
13848
13945
|
});
|
|
13849
13946
|
window.addEventListener('pagehide', () => {
|
|
13850
|
-
|
|
13851
|
-
|
|
13852
|
-
sessionStorage.setItem(TERMINAL_FOCUS_KEY, currentSessionId);
|
|
13853
|
-
} else sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
|
|
13854
|
-
} catch (_) {}
|
|
13947
|
+
if (termState.attached && termState.shown && terminalHadFocus) tabStore.setItem(TERMINAL_FOCUS_KEY, currentSessionId);
|
|
13948
|
+
else tabStore.removeItem(TERMINAL_FOCUS_KEY);
|
|
13855
13949
|
});
|
|
13856
13950
|
|
|
13857
13951
|
function restoreTerminalFocus(sessionId) {
|
|
13858
|
-
|
|
13859
|
-
|
|
13860
|
-
sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
|
|
13861
|
-
} catch (_) {}
|
|
13952
|
+
if (tabStore.getItem(TERMINAL_FOCUS_KEY) === sessionId) termState.focusNext = true;
|
|
13953
|
+
tabStore.removeItem(TERMINAL_FOCUS_KEY);
|
|
13862
13954
|
}
|
|
13863
13955
|
|
|
13864
13956
|
// The terminal stands in for the board, so it takes the board zone however focus arrives
|
|
@@ -13921,6 +14013,7 @@ async function openTerminal(sessionId, mode, attempt = 0) {
|
|
|
13921
14013
|
model: spec.model,
|
|
13922
14014
|
prompt: spec.prompt,
|
|
13923
14015
|
edit: spec.edit,
|
|
14016
|
+
forkOf: spec.forkOf,
|
|
13924
14017
|
}),
|
|
13925
14018
|
},
|
|
13926
14019
|
});
|
|
@@ -14012,7 +14105,6 @@ function onTerminalMessage(sessionId, msg, tail = '') {
|
|
|
14012
14105
|
'Resuming it here too makes both processes write to the same transcript.',
|
|
14013
14106
|
[
|
|
14014
14107
|
['resume', 'Resume anyway'],
|
|
14015
|
-
['fork', 'Fork'],
|
|
14016
14108
|
['shell', 'Shell only'],
|
|
14017
14109
|
],
|
|
14018
14110
|
);
|
|
@@ -14037,7 +14129,6 @@ function onTerminalMessage(sessionId, msg, tail = '') {
|
|
|
14037
14129
|
]
|
|
14038
14130
|
: [
|
|
14039
14131
|
['resume', 'Resume'],
|
|
14040
|
-
['fork', 'Fork'],
|
|
14041
14132
|
['shell', 'Shell'],
|
|
14042
14133
|
],
|
|
14043
14134
|
clean ? '' : tail,
|
|
@@ -14054,14 +14145,16 @@ function endTerminalSession() {
|
|
|
14054
14145
|
}
|
|
14055
14146
|
|
|
14056
14147
|
// Detaching first means no exit message reaches the pane, so no Resume prompt shows before the board.
|
|
14057
|
-
|
|
14148
|
+
// Terminal mode must go too: left on, opening the session again reconnects in 'auto' and resumes claude.
|
|
14149
|
+
async function closeTerminalSession(id = termState.sessionId) {
|
|
14058
14150
|
if (id === termState.sessionId) {
|
|
14059
14151
|
if (terminalPaneFocused()) leaveTerminalPane();
|
|
14060
14152
|
detachTerminal();
|
|
14061
14153
|
}
|
|
14062
14154
|
setTerminalMode(id, false);
|
|
14063
14155
|
syncTerminal();
|
|
14064
|
-
|
|
14156
|
+
await terminalFetch(apiPath`/api/terminals/${id}`, 'DELETE').catch(() => {});
|
|
14157
|
+
dropPlaceholder(id);
|
|
14065
14158
|
}
|
|
14066
14159
|
|
|
14067
14160
|
function openTerminalManager() {
|
|
@@ -14094,13 +14187,9 @@ async function pollTerminalProcStats() {
|
|
|
14094
14187
|
if (!isOnScreen()) return;
|
|
14095
14188
|
let cck;
|
|
14096
14189
|
let host;
|
|
14097
|
-
|
|
14098
|
-
|
|
14099
|
-
|
|
14100
|
-
({ cck, host, terminals: terminalProcStats } = await res.json());
|
|
14101
|
-
} catch (_) {
|
|
14102
|
-
return;
|
|
14103
|
-
}
|
|
14190
|
+
const stats = await getJson('/api/terminals/stats', null, { cache: 'no-store' });
|
|
14191
|
+
if (!stats) return;
|
|
14192
|
+
({ cck, host, terminals: terminalProcStats } = stats);
|
|
14104
14193
|
const parts = [cck && `cck ${formatTerminalProc(cck)}`, host && `terminal host ${formatTerminalProc(host)}`];
|
|
14105
14194
|
document.getElementById('terminal-manager-stats').textContent = parts.filter(Boolean).join(' · ');
|
|
14106
14195
|
for (const el of document.querySelectorAll('#terminal-manager-body [data-proc]')) {
|
|
@@ -14161,35 +14250,22 @@ async function renderTerminalManager() {
|
|
|
14161
14250
|
body.querySelectorAll('[data-end]').forEach((b) => {
|
|
14162
14251
|
b.onclick = async () => {
|
|
14163
14252
|
b.disabled = true;
|
|
14164
|
-
await
|
|
14253
|
+
await closeTerminalSession(b.dataset.end);
|
|
14165
14254
|
renderTerminalManager();
|
|
14166
14255
|
};
|
|
14167
14256
|
});
|
|
14168
14257
|
}
|
|
14169
14258
|
|
|
14170
14259
|
async function terminalFetch(url, method, body) {
|
|
14171
|
-
const send = () =>
|
|
14172
|
-
fetch(url, {
|
|
14173
|
-
method,
|
|
14174
|
-
headers: {
|
|
14175
|
-
'X-Terminal-Token': terminalToken || '',
|
|
14176
|
-
...(body && { 'Content-Type': 'application/json' }),
|
|
14177
|
-
},
|
|
14178
|
-
...(body && { body: JSON.stringify(body) }),
|
|
14179
|
-
});
|
|
14260
|
+
const send = () => api(url, { method, body, headers: { 'X-Terminal-Token': terminalToken || '' } });
|
|
14180
14261
|
const res = await send();
|
|
14181
14262
|
return res.status === 401 && (await refreshTerminalToken()) ? send() : res;
|
|
14182
14263
|
}
|
|
14183
14264
|
|
|
14184
|
-
async function endTerminal(id) {
|
|
14185
|
-
await terminalFetch(`/api/terminals/${encodeURIComponent(id)}`, 'DELETE').catch(() => {});
|
|
14186
|
-
dropPlaceholder(id);
|
|
14187
|
-
}
|
|
14188
|
-
|
|
14189
14265
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
14190
14266
|
async function endAllTerminals() {
|
|
14191
14267
|
const buttons = document.querySelectorAll('#terminal-manager-body [data-end]');
|
|
14192
|
-
await Promise.all([...buttons].map((b) =>
|
|
14268
|
+
await Promise.all([...buttons].map((b) => closeTerminalSession(b.dataset.end)));
|
|
14193
14269
|
renderTerminalManager();
|
|
14194
14270
|
}
|
|
14195
14271
|
|
|
@@ -14220,13 +14296,18 @@ const PLACEHOLDER_HINTS = {
|
|
|
14220
14296
|
edit: 'press Enter in the terminal to start',
|
|
14221
14297
|
};
|
|
14222
14298
|
|
|
14299
|
+
// The parent may be outside the loaded list, so its short id stands in for its name.
|
|
14300
|
+
function forkLabel(parentId) {
|
|
14301
|
+
return `Fork of ${sessionDisplayName(sessions.find((s) => s.id === parentId) || { id: parentId })}`;
|
|
14302
|
+
}
|
|
14303
|
+
|
|
14223
14304
|
function placeholderSession(id, spec) {
|
|
14224
14305
|
return {
|
|
14225
14306
|
id,
|
|
14226
14307
|
placeholder: true,
|
|
14227
14308
|
mode: spec.mode,
|
|
14228
14309
|
edit: !!spec.edit,
|
|
14229
|
-
name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
14310
|
+
name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
14230
14311
|
dispatchGroup: spec.group || undefined,
|
|
14231
14312
|
dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
|
|
14232
14313
|
project: spec.cwd,
|
|
@@ -14250,23 +14331,21 @@ function mergePlaceholders(list) {
|
|
|
14250
14331
|
}
|
|
14251
14332
|
|
|
14252
14333
|
async function loadDispatches() {
|
|
14253
|
-
|
|
14254
|
-
|
|
14255
|
-
|
|
14256
|
-
|
|
14257
|
-
|
|
14258
|
-
|
|
14259
|
-
|
|
14260
|
-
|
|
14261
|
-
|
|
14262
|
-
|
|
14263
|
-
|
|
14264
|
-
|
|
14265
|
-
|
|
14266
|
-
|
|
14267
|
-
|
|
14268
|
-
}
|
|
14269
|
-
} catch (_) {}
|
|
14334
|
+
const data = await getJson('/api/dispatch', null, { cache: 'no-store' });
|
|
14335
|
+
if (!data) return;
|
|
14336
|
+
dispatchSpecs.clear();
|
|
14337
|
+
for (const r of data.running || []) {
|
|
14338
|
+
if (!r.session || sessions.some((s) => s.id === r.session && !s.placeholder)) continue;
|
|
14339
|
+
dispatchSpecs.set(r.session, {
|
|
14340
|
+
mode: 'dispatch',
|
|
14341
|
+
cwd: r.cwd,
|
|
14342
|
+
name: r.name,
|
|
14343
|
+
worktree: r.worktree,
|
|
14344
|
+
group: r.group,
|
|
14345
|
+
parent: r.parent,
|
|
14346
|
+
startedAt: r.startedAt,
|
|
14347
|
+
});
|
|
14348
|
+
}
|
|
14270
14349
|
}
|
|
14271
14350
|
|
|
14272
14351
|
async function onDispatchUpdate() {
|
|
@@ -14325,6 +14404,7 @@ async function restorePendingSessions() {
|
|
|
14325
14404
|
worktree: t.worktree,
|
|
14326
14405
|
mode: t.mode,
|
|
14327
14406
|
edit: t.edit,
|
|
14407
|
+
forkOf: t.forkOf,
|
|
14328
14408
|
startedAt: t.startedAt,
|
|
14329
14409
|
});
|
|
14330
14410
|
}
|
|
@@ -14332,8 +14412,10 @@ async function restorePendingSessions() {
|
|
|
14332
14412
|
}
|
|
14333
14413
|
|
|
14334
14414
|
async function loadTerminals() {
|
|
14335
|
-
const res = await
|
|
14336
|
-
const
|
|
14415
|
+
const res = await api('/api/terminals', { cache: 'no-store' });
|
|
14416
|
+
const data = await res.json();
|
|
14417
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
14418
|
+
const list = data.sessions || [];
|
|
14337
14419
|
runningTerminals = new Set(list.map((t) => t.id));
|
|
14338
14420
|
pushTerminalClaims();
|
|
14339
14421
|
renderActivityChip();
|
|
@@ -14553,11 +14635,29 @@ function startNewSession() {
|
|
|
14553
14635
|
newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
|
|
14554
14636
|
setTerminalMode(id, true);
|
|
14555
14637
|
if (v.stay) return startNewSessionInBackground(id, v);
|
|
14556
|
-
sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
|
|
14557
14638
|
closeNewSession();
|
|
14639
|
+
openPlaceholder(id);
|
|
14640
|
+
}
|
|
14641
|
+
|
|
14642
|
+
function openPlaceholder(id) {
|
|
14643
|
+
sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
|
|
14558
14644
|
fetchTasks(id).then(() => sessionsList.querySelector('.session-item.active')?.scrollIntoView({ block: 'nearest' }));
|
|
14559
14645
|
}
|
|
14560
14646
|
|
|
14647
|
+
function forkableSession(id) {
|
|
14648
|
+
return terminalAvailable() ? sessions.find((s) => s.id === id && !s.placeholder) : undefined;
|
|
14649
|
+
}
|
|
14650
|
+
|
|
14651
|
+
// The fork gets its own id up front, so it runs as a new session and needs no rekey.
|
|
14652
|
+
function forkSession(parentId) {
|
|
14653
|
+
const parent = forkableSession(parentId);
|
|
14654
|
+
if (!parent) return;
|
|
14655
|
+
const id = crypto.randomUUID();
|
|
14656
|
+
newSpecs.set(id, { cwd: parent.project, forkOf: parentId, mode: 'new', startedAt: Date.now() });
|
|
14657
|
+
setTerminalMode(id, true);
|
|
14658
|
+
openPlaceholder(id);
|
|
14659
|
+
}
|
|
14660
|
+
|
|
14561
14661
|
async function startNewSessionInBackground(id, v) {
|
|
14562
14662
|
refreshPlaceholders();
|
|
14563
14663
|
setNewSessionBusy(true);
|
|
@@ -14704,7 +14804,7 @@ if ('serviceWorker' in navigator) {
|
|
|
14704
14804
|
|
|
14705
14805
|
//#region INIT
|
|
14706
14806
|
loadTheme();
|
|
14707
|
-
if (store.getItem('sessions-
|
|
14807
|
+
if (store.getItem(sectionCollapsedKey('sessions-filters')) === 'true') {
|
|
14708
14808
|
document.getElementById('sessions-filters').classList.add('collapsed');
|
|
14709
14809
|
document.getElementById('sessions-chevron').classList.add('rotated');
|
|
14710
14810
|
}
|
|
@@ -14743,27 +14843,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
14743
14843
|
});
|
|
14744
14844
|
|
|
14745
14845
|
loadSidebarState();
|
|
14746
|
-
|
|
14747
|
-
const cg = JSON.parse(store.getItem('collapsedGroups') || '[]');
|
|
14748
|
-
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
14749
|
-
cg.forEach((p) => collapsedProjectGroups.add(p));
|
|
14750
|
-
} catch (_) {}
|
|
14846
|
+
for (const p of readStoredList(COLLAPSED_GROUPS_KEY)) collapsedProjectGroups.add(p);
|
|
14751
14847
|
loadSessionGroups();
|
|
14752
14848
|
initSessionGroupsDnd();
|
|
14753
14849
|
initSessionPicker();
|
|
14754
14850
|
initProjectPicker();
|
|
14755
|
-
|
|
14756
|
-
const af = JSON.parse(store.getItem('activityFilter') || '[]');
|
|
14757
|
-
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
14758
|
-
af.forEach((k) => activityFilter.add(k));
|
|
14759
|
-
} catch (_) {}
|
|
14851
|
+
for (const k of readStoredList(ACTIVITY_FILTER_KEY)) activityFilter.add(k);
|
|
14760
14852
|
initSidebarResize();
|
|
14761
14853
|
applyModalZoom();
|
|
14762
14854
|
loadModalFullscreen();
|
|
14763
14855
|
initModalResize();
|
|
14764
14856
|
loadPanelWidths();
|
|
14765
|
-
initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width'
|
|
14766
|
-
initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width'
|
|
14857
|
+
initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width');
|
|
14858
|
+
initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width');
|
|
14767
14859
|
initMessagePanelFull();
|
|
14768
14860
|
|
|
14769
14861
|
const msgContentEl = document.getElementById('message-panel-content');
|
|
@@ -14876,12 +14968,7 @@ function pluginWarning(plugin) {
|
|
|
14876
14968
|
btn.title = `${problem} (${plugin.configDir}).\n${fix}`;
|
|
14877
14969
|
btn.setAttribute('aria-label', problem);
|
|
14878
14970
|
if (plugin.state !== 'disabled') {
|
|
14879
|
-
btn.addEventListener('click', () =>
|
|
14880
|
-
navigator.clipboard
|
|
14881
|
-
.writeText(plugin.installCommand)
|
|
14882
|
-
.then(() => showToast('Install command copied', 'success'))
|
|
14883
|
-
.catch(() => showToast('Could not copy the command', 'error')),
|
|
14884
|
-
);
|
|
14971
|
+
btn.addEventListener('click', () => copyText(plugin.installCommand, 'install command'));
|
|
14885
14972
|
}
|
|
14886
14973
|
return btn;
|
|
14887
14974
|
}
|
|
@@ -14890,18 +14977,17 @@ function refreshRateLimits() {
|
|
|
14890
14977
|
if (footerState.timer) return;
|
|
14891
14978
|
footerState.timer = setTimeout(() => {
|
|
14892
14979
|
footerState.timer = null;
|
|
14893
|
-
|
|
14894
|
-
|
|
14895
|
-
|
|
14896
|
-
|
|
14897
|
-
|
|
14898
|
-
|
|
14899
|
-
|
|
14900
|
-
|
|
14901
|
-
|
|
14902
|
-
|
|
14903
|
-
|
|
14904
|
-
.catch(() => {});
|
|
14980
|
+
// The route answers null when no session has reported limits, so failure needs its own value.
|
|
14981
|
+
getJson('/api/rate-limits', false).then((rl) => {
|
|
14982
|
+
if (rl === false) return;
|
|
14983
|
+
scheduleRateLimitReset(rl);
|
|
14984
|
+
const fh = rl?.five_hour?.used_percentage ?? null;
|
|
14985
|
+
const sd = rl?.seven_day?.used_percentage ?? null;
|
|
14986
|
+
const key = `${fh}|${sd}`;
|
|
14987
|
+
if (key === footerState.limitsKey) return;
|
|
14988
|
+
footerState.limitsKey = key;
|
|
14989
|
+
renderSidebarFooter(rl);
|
|
14990
|
+
});
|
|
14905
14991
|
}, 1500);
|
|
14906
14992
|
}
|
|
14907
14993
|
|
|
@@ -14916,15 +15002,12 @@ function scheduleRateLimitReset(rl) {
|
|
|
14916
15002
|
const ms = Math.min(...resets) * 1000 - Date.now() + 1000;
|
|
14917
15003
|
footerState.resetTimer = setTimeout(refreshRateLimits, Math.min(Math.max(ms, 0), 2 ** 31 - 1));
|
|
14918
15004
|
}
|
|
14919
|
-
|
|
14920
|
-
.
|
|
14921
|
-
.
|
|
14922
|
-
|
|
14923
|
-
|
|
14924
|
-
|
|
14925
|
-
refreshRateLimits();
|
|
14926
|
-
})
|
|
14927
|
-
.catch(() => {});
|
|
15005
|
+
getJson('/api/version', {}).then((d) => {
|
|
15006
|
+
footerState.version = d.version;
|
|
15007
|
+
footerState.plugin = d.plugin ?? null;
|
|
15008
|
+
renderSidebarFooter(null);
|
|
15009
|
+
refreshRateLimits();
|
|
15010
|
+
});
|
|
14928
15011
|
|
|
14929
15012
|
const urlState = getUrlState();
|
|
14930
15013
|
const lastView = loadLastView();
|
|
@@ -14951,12 +15034,10 @@ if (urlState.search) {
|
|
|
14951
15034
|
document.getElementById('boot-verb').textContent = pickRandom(verbs);
|
|
14952
15035
|
}
|
|
14953
15036
|
|
|
14954
|
-
|
|
14955
|
-
.then((r) => r.json())
|
|
15037
|
+
getJson('/api/config')
|
|
14956
15038
|
.then((c) => {
|
|
14957
|
-
appConfig = c;
|
|
15039
|
+
if (c) appConfig = c;
|
|
14958
15040
|
})
|
|
14959
|
-
.catch(() => {})
|
|
14960
15041
|
.then(restorePendingSessions)
|
|
14961
15042
|
.then(() =>
|
|
14962
15043
|
fetchSessions(
|