claude-code-kanban 7.4.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 +1 -1
- 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 +1019 -1001
- package/public/index.html +13 -31
- package/public/style.css +190 -29
- 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.
|
|
@@ -118,28 +164,19 @@ const dismissedSessionIds = loadDismissedSessions();
|
|
|
118
164
|
let dismissSaveWarned = false;
|
|
119
165
|
|
|
120
166
|
function loadDismissedSessions() {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return new Map(saved.filter(([, at]) => isDismissFresh(at)));
|
|
124
|
-
} catch {
|
|
125
|
-
return new Map();
|
|
126
|
-
}
|
|
167
|
+
const saved = store.readJson(DISMISSED_KEY, {});
|
|
168
|
+
return new Map(Object.entries(saved).filter(([, at]) => isDismissFresh(at)));
|
|
127
169
|
}
|
|
128
170
|
|
|
129
171
|
function persistDismissedSessions() {
|
|
130
172
|
for (const [id, at] of dismissedSessionIds) if (!isDismissFresh(at)) dismissedSessionIds.delete(id);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
showToast(
|
|
139
|
-
'Browser storage is full: dismissed sessions come back on reload. Storage → Clean Orphaned frees space.',
|
|
140
|
-
'error',
|
|
141
|
-
);
|
|
142
|
-
}
|
|
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
|
+
);
|
|
143
180
|
}
|
|
144
181
|
}
|
|
145
182
|
|
|
@@ -158,7 +195,7 @@ function resetMessageScrollState() {
|
|
|
158
195
|
msgUserScrolledUp = false;
|
|
159
196
|
msgHasMore = false;
|
|
160
197
|
msgLoadingMore = false;
|
|
161
|
-
|
|
198
|
+
setCurrentMessages([]);
|
|
162
199
|
lastMessagesHash = '';
|
|
163
200
|
const btn = document.getElementById('msg-jump-latest');
|
|
164
201
|
if (btn) btn.style.display = 'none';
|
|
@@ -172,7 +209,9 @@ function getUrlState() {
|
|
|
172
209
|
project: params.get('project'),
|
|
173
210
|
owner: params.get('owner'),
|
|
174
211
|
search: params.get('search'),
|
|
175
|
-
messages: params.has('messages')
|
|
212
|
+
messages: params.has('messages')
|
|
213
|
+
? params.get('messages') === '1'
|
|
214
|
+
: store.getItem(MESSAGE_PANEL_OPEN_KEY) === 'true',
|
|
176
215
|
projectView: params.get('projectView'),
|
|
177
216
|
};
|
|
178
217
|
}
|
|
@@ -196,32 +235,23 @@ function updateUrl() {
|
|
|
196
235
|
|
|
197
236
|
const LAST_VIEW_KEY = 'lastView';
|
|
198
237
|
function persistLastView() {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
};
|
|
208
|
-
store.setItem(LAST_VIEW_KEY, JSON.stringify(data));
|
|
209
|
-
} 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
|
+
});
|
|
210
246
|
}
|
|
211
247
|
function loadLastView() {
|
|
212
|
-
|
|
213
|
-
return JSON.parse(store.getItem(LAST_VIEW_KEY)) || null;
|
|
214
|
-
} catch (_) {
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
248
|
+
return store.readJson(LAST_VIEW_KEY);
|
|
217
249
|
}
|
|
218
250
|
|
|
219
251
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
220
252
|
function resetState() {
|
|
221
253
|
history.replaceState(null, '', window.location.pathname);
|
|
222
|
-
|
|
223
|
-
store.removeItem(LAST_VIEW_KEY);
|
|
224
|
-
} catch (_) {}
|
|
254
|
+
store.removeItem(LAST_VIEW_KEY);
|
|
225
255
|
sessionFilter = FILTER_DEFAULTS.session;
|
|
226
256
|
sessionLimit = SESSION_PAGE;
|
|
227
257
|
filterProject = FILTER_DEFAULTS.project;
|
|
@@ -267,31 +297,52 @@ let lastTasksHash = '';
|
|
|
267
297
|
//#endregion
|
|
268
298
|
|
|
269
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
|
+
|
|
270
324
|
async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
|
|
271
325
|
try {
|
|
272
326
|
const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
|
|
273
327
|
if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
|
|
274
328
|
if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
:
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
`/api/sessions?limit=${sessionLimit}${pinnedParam}${includeParam}${projectParam}${filterParam}`,
|
|
289
|
-
).then((r) => {
|
|
290
|
-
sessionsHasMore = r.headers.get('X-Has-More') === 'true';
|
|
291
|
-
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
|
+
},
|
|
292
342
|
});
|
|
293
|
-
|
|
294
|
-
|
|
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();
|
|
295
346
|
const sessionsHash = JSON.stringify(newSessions);
|
|
296
347
|
if (sessionsHash !== lastSessionsHash) {
|
|
297
348
|
lastSessionsHash = sessionsHash;
|
|
@@ -316,9 +367,7 @@ function markSessionRead(sessionId) {
|
|
|
316
367
|
if (!session?.unread) return;
|
|
317
368
|
session.unread = false;
|
|
318
369
|
document.querySelector(`.session-item[data-session-id="${CSS.escape(sessionId)}"]`)?.classList.remove('unread');
|
|
319
|
-
|
|
320
|
-
console.error('[markSessionRead]', e),
|
|
321
|
-
);
|
|
370
|
+
api(apiPath`/api/sessions/${sessionId}/read`, { method: 'POST' }).catch((e) => console.error('[markSessionRead]', e));
|
|
322
371
|
}
|
|
323
372
|
|
|
324
373
|
function markOpenSessionRead() {
|
|
@@ -326,7 +375,8 @@ function markOpenSessionRead() {
|
|
|
326
375
|
}
|
|
327
376
|
|
|
328
377
|
async function loadSearchTasks() {
|
|
329
|
-
const newTasks = await
|
|
378
|
+
const newTasks = await getJson('/api/tasks/all');
|
|
379
|
+
if (!newTasks) return;
|
|
330
380
|
const tasksHash = JSON.stringify(newTasks);
|
|
331
381
|
if (tasksHash === lastTasksHash) return;
|
|
332
382
|
lastTasksHash = tasksHash;
|
|
@@ -360,14 +410,9 @@ sessionsList.addEventListener('scroll', loadMoreIfNearEnd, { passive: true });
|
|
|
360
410
|
|
|
361
411
|
// Sessions by id, in the order given, ignoring every sidebar filter — that is what `include` means.
|
|
362
412
|
async function fetchSessionsByIds(ids) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const byId = new Map((await r.json()).map((s) => [s.id, s]));
|
|
367
|
-
return ids.map((id) => byId.get(id)).filter(Boolean);
|
|
368
|
-
} catch (_) {
|
|
369
|
-
return [];
|
|
370
|
-
}
|
|
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);
|
|
371
416
|
}
|
|
372
417
|
|
|
373
418
|
async function fetchSessionById(id) {
|
|
@@ -401,11 +446,7 @@ function sessionSearch(onFound) {
|
|
|
401
446
|
let found = null;
|
|
402
447
|
let timer = null;
|
|
403
448
|
const run = async (key) => {
|
|
404
|
-
|
|
405
|
-
try {
|
|
406
|
-
const r = await fetch(`/api/sessions/search?q=${encodeURIComponent(key)}`);
|
|
407
|
-
if (r.ok) ids = await r.json();
|
|
408
|
-
} catch (_) {}
|
|
449
|
+
const ids = await getJson('/api/sessions/search', [], { query: { q: key } });
|
|
409
450
|
if (key !== current) return;
|
|
410
451
|
const rows = ids.length ? await fetchSessionsByIds(ids) : [];
|
|
411
452
|
if (key !== current) return;
|
|
@@ -492,13 +533,14 @@ function fuzzyMatch(text, query) {
|
|
|
492
533
|
|
|
493
534
|
//#endregion
|
|
494
535
|
|
|
536
|
+
//#region SESSION_ACTIVITY
|
|
495
537
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
496
538
|
function toggleSection(containerId, chevronId) {
|
|
497
539
|
const container = document.getElementById(containerId);
|
|
498
540
|
const chevron = document.getElementById(chevronId);
|
|
499
541
|
const collapsed = container.classList.toggle('collapsed');
|
|
500
542
|
chevron.classList.toggle('rotated', collapsed);
|
|
501
|
-
store.setItem(
|
|
543
|
+
store.setItem(sectionCollapsedKey(containerId), collapsed);
|
|
502
544
|
}
|
|
503
545
|
|
|
504
546
|
function isWaitingSession(s) {
|
|
@@ -635,7 +677,7 @@ function setActivityFilter(kind) {
|
|
|
635
677
|
} else {
|
|
636
678
|
toggleActivityKind(kind);
|
|
637
679
|
}
|
|
638
|
-
store.
|
|
680
|
+
store.writeJson(ACTIVITY_FILTER_KEY, [...activityFilter]);
|
|
639
681
|
// active/waiting only make sense with the active session filter on
|
|
640
682
|
const targetFilter = activityFilter.size > 0 ? 'active' : sessionFilter;
|
|
641
683
|
if (targetFilter !== sessionFilter) {
|
|
@@ -658,7 +700,7 @@ async function fetchTasks(sessionId) {
|
|
|
658
700
|
openTerminal(sessionId, terminalOpenMode(sessionId));
|
|
659
701
|
viewMode = 'session';
|
|
660
702
|
document.getElementById('message-toggle')?.style.removeProperty('display');
|
|
661
|
-
const res = await
|
|
703
|
+
const res = await api(apiPath`/api/sessions/${sessionId}`);
|
|
662
704
|
|
|
663
705
|
if (!res.ok) throw new Error(`Failed to fetch tasks: ${res.status}`);
|
|
664
706
|
const newTasks = await res.json();
|
|
@@ -710,14 +752,16 @@ async function fetchTasks(sessionId) {
|
|
|
710
752
|
renderSession();
|
|
711
753
|
}
|
|
712
754
|
}
|
|
755
|
+
//#endregion
|
|
713
756
|
|
|
714
|
-
|
|
757
|
+
//#region TIMINGS
|
|
715
758
|
const WAITING_TTL_MS = 30 * 60 * 1000;
|
|
716
759
|
const AGENT_LOG_MAX = 8;
|
|
717
760
|
const LIVE_INDICATOR_MS = 10 * 1000;
|
|
718
761
|
const JUST_NOW_MS = 60 * 1000;
|
|
719
|
-
|
|
762
|
+
//#endregion
|
|
720
763
|
|
|
764
|
+
//#region AGENT_FETCHING
|
|
721
765
|
function resetAgentState() {
|
|
722
766
|
currentAgents = [];
|
|
723
767
|
currentWaiting = null;
|
|
@@ -728,7 +772,7 @@ function resetAgentState() {
|
|
|
728
772
|
|
|
729
773
|
async function fetchAgents(sessionId) {
|
|
730
774
|
try {
|
|
731
|
-
const res = await
|
|
775
|
+
const res = await api(apiPath`/api/sessions/${sessionId}/agents`);
|
|
732
776
|
if (!res.ok) {
|
|
733
777
|
resetAgentState();
|
|
734
778
|
return;
|
|
@@ -760,7 +804,7 @@ async function fetchProjectView(projectPath) {
|
|
|
760
804
|
viewMode = 'project';
|
|
761
805
|
currentProjectPath = projectPath;
|
|
762
806
|
currentSessionId = null;
|
|
763
|
-
|
|
807
|
+
setCurrentMessages([]);
|
|
764
808
|
lastMessagesHash = '';
|
|
765
809
|
if (messagePanelOpen) toggleMessagePanel();
|
|
766
810
|
document.getElementById('message-toggle')?.style.setProperty('display', 'none');
|
|
@@ -774,19 +818,8 @@ async function fetchProjectView(projectPath) {
|
|
|
774
818
|
|
|
775
819
|
const encoded = btoa(projectPath);
|
|
776
820
|
const [tasksResult, agentResults] = await Promise.all([
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
.catch((e) => {
|
|
780
|
-
console.error('[fetchProjectView] tasks:', e);
|
|
781
|
-
return [];
|
|
782
|
-
}),
|
|
783
|
-
Promise.all(
|
|
784
|
-
activeSessionIds.map((id) =>
|
|
785
|
-
fetch(`/api/sessions/${id}/agents`)
|
|
786
|
-
.then((r) => r.json())
|
|
787
|
-
.catch(() => ({ agents: [] })),
|
|
788
|
-
),
|
|
789
|
-
),
|
|
821
|
+
getJson(apiPath`/api/projects/${encoded}/tasks`, []),
|
|
822
|
+
Promise.all(activeSessionIds.map((id) => getJson(apiPath`/api/sessions/${id}/agents`, { agents: [] }))),
|
|
790
823
|
]);
|
|
791
824
|
currentTasks = tasksResult;
|
|
792
825
|
const seen = new Set();
|
|
@@ -821,11 +854,7 @@ async function refreshProjectAgents() {
|
|
|
821
854
|
const projectSessions = sessions.filter((s) => s.project === currentProjectPath);
|
|
822
855
|
const activeSessionIds = projectSessions.filter((s) => isSessionActive(s) || isAnyPinned(s.id)).map((s) => s.id);
|
|
823
856
|
const agentResults = await Promise.all(
|
|
824
|
-
activeSessionIds.map((id) =>
|
|
825
|
-
fetch(`/api/sessions/${id}/agents`)
|
|
826
|
-
.then((r) => r.json())
|
|
827
|
-
.catch(() => ({ agents: [] })),
|
|
828
|
-
),
|
|
857
|
+
activeSessionIds.map((id) => getJson(apiPath`/api/sessions/${id}/agents`, { agents: [] })),
|
|
829
858
|
);
|
|
830
859
|
const seen = new Set();
|
|
831
860
|
currentAgents = [];
|
|
@@ -850,6 +879,7 @@ async function refreshProjectAgents() {
|
|
|
850
879
|
lastAgentsHash = hash;
|
|
851
880
|
renderAgentFooter();
|
|
852
881
|
}
|
|
882
|
+
//#endregion
|
|
853
883
|
|
|
854
884
|
//#region MESSAGE_PANEL
|
|
855
885
|
function setMessagePanelVisible(open) {
|
|
@@ -879,7 +909,7 @@ function toggleMessagePanel() {
|
|
|
879
909
|
const panel = document.getElementById('message-panel');
|
|
880
910
|
messagePanelOpen = !messagePanelOpen;
|
|
881
911
|
termState.panelHidden = false;
|
|
882
|
-
store.setItem(
|
|
912
|
+
store.setItem(MESSAGE_PANEL_OPEN_KEY, messagePanelOpen);
|
|
883
913
|
panel.classList.toggle('visible', messagePanelOpen);
|
|
884
914
|
document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
|
|
885
915
|
if (messagePanelOpen && currentSessionId) {
|
|
@@ -902,7 +932,7 @@ function applyMessagePanelFull(on) {
|
|
|
902
932
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
903
933
|
function toggleMessagePanelFull() {
|
|
904
934
|
const on = !document.getElementById('message-panel').classList.contains('full-width');
|
|
905
|
-
store.setItem(
|
|
935
|
+
store.setItem(MESSAGE_PANEL_FULL_KEY, on);
|
|
906
936
|
applyMessagePanelFull(on);
|
|
907
937
|
}
|
|
908
938
|
|
|
@@ -914,7 +944,7 @@ function initMessagePanelFull() {
|
|
|
914
944
|
new ResizeObserver(sync).observe(sidebar);
|
|
915
945
|
sync();
|
|
916
946
|
}
|
|
917
|
-
applyMessagePanelFull(store.getItem(
|
|
947
|
+
applyMessagePanelFull(store.getItem(MESSAGE_PANEL_FULL_KEY) === 'true');
|
|
918
948
|
}
|
|
919
949
|
|
|
920
950
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
@@ -922,7 +952,7 @@ async function openSessionWithBookmarks(sessionId) {
|
|
|
922
952
|
if (!messagePanelOpen) {
|
|
923
953
|
const panel = document.getElementById('message-panel');
|
|
924
954
|
messagePanelOpen = true;
|
|
925
|
-
store.setItem(
|
|
955
|
+
store.setItem(MESSAGE_PANEL_OPEN_KEY, 'true');
|
|
926
956
|
panel.classList.add('visible');
|
|
927
957
|
document.getElementById('message-toggle')?.classList.add('active');
|
|
928
958
|
}
|
|
@@ -964,12 +994,12 @@ async function viewAgentLog(agentId) {
|
|
|
964
994
|
agentLogSSE.close();
|
|
965
995
|
agentLogSSE = null;
|
|
966
996
|
}
|
|
967
|
-
agentLogSSE = new EventSource(`/api/sessions/${agentLogMode.sessionId}/agents/${resolvedId}/messages/stream`);
|
|
997
|
+
agentLogSSE = new EventSource(apiPath`/api/sessions/${agentLogMode.sessionId}/agents/${resolvedId}/messages/stream`);
|
|
968
998
|
agentLogSSE.addEventListener('agent-log-update', (e) => {
|
|
969
999
|
if (!agentLogMode || agentLogMode.agentId !== resolvedId) return;
|
|
970
1000
|
try {
|
|
971
1001
|
const data = JSON.parse(e.data);
|
|
972
|
-
|
|
1002
|
+
setCurrentMessages(data.messages);
|
|
973
1003
|
if (messagePanelOpen) renderMessages(data.messages);
|
|
974
1004
|
maybeFollowLatest();
|
|
975
1005
|
} catch (_) {}
|
|
@@ -998,11 +1028,11 @@ async function fetchAgentMessages() {
|
|
|
998
1028
|
if (!agentLogMode) return;
|
|
999
1029
|
const { sessionId, agentId } = agentLogMode;
|
|
1000
1030
|
try {
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
if (!agentLogMode || agentLogMode.agentId !== agentId) return;
|
|
1005
|
-
|
|
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);
|
|
1006
1036
|
if (messagePanelOpen) renderMessages(data.messages);
|
|
1007
1037
|
maybeFollowLatest();
|
|
1008
1038
|
} catch (e) {
|
|
@@ -1020,10 +1050,8 @@ function openLiveLatestMessage() {
|
|
|
1020
1050
|
|
|
1021
1051
|
const MSG_PAGE_LIMIT = 15;
|
|
1022
1052
|
|
|
1023
|
-
async function fetchSessionMessagesPage(sessionId, { before } = {}) {
|
|
1024
|
-
const
|
|
1025
|
-
if (before) qs.set('before', before);
|
|
1026
|
-
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 } });
|
|
1027
1055
|
return res.ok ? res.json() : null;
|
|
1028
1056
|
}
|
|
1029
1057
|
|
|
@@ -1049,18 +1077,16 @@ async function fetchMessages(sessionId) {
|
|
|
1049
1077
|
if (hash === lastMessagesHash) return;
|
|
1050
1078
|
lastMessagesHash = hash;
|
|
1051
1079
|
msgHasMore = data.hasMore !== false;
|
|
1052
|
-
|
|
1080
|
+
setCurrentMessages(data.messages);
|
|
1053
1081
|
if (messagePanelOpen) renderMessages(data.messages);
|
|
1054
1082
|
} else {
|
|
1055
1083
|
if (data.messages.length && currentMessages.length) {
|
|
1056
1084
|
const lastKnown = currentMessages[currentMessages.length - 1].timestamp;
|
|
1057
1085
|
const newMsgs = data.messages.filter((m) => m.timestamp > lastKnown);
|
|
1058
1086
|
if (newMsgs.length) {
|
|
1059
|
-
|
|
1060
|
-
if (
|
|
1061
|
-
|
|
1062
|
-
msgHasMore = true;
|
|
1063
|
-
}
|
|
1087
|
+
const merged = [...currentMessages, ...newMsgs];
|
|
1088
|
+
if (merged.length > MSG_MAX_LOADED) msgHasMore = true;
|
|
1089
|
+
setCurrentMessages(merged.slice(-MSG_MAX_LOADED));
|
|
1064
1090
|
if (messagePanelOpen) renderMessages(currentMessages);
|
|
1065
1091
|
}
|
|
1066
1092
|
}
|
|
@@ -1087,10 +1113,7 @@ async function loadOlderMessages() {
|
|
|
1087
1113
|
if (data.messages.length) {
|
|
1088
1114
|
loader.remove();
|
|
1089
1115
|
const prevHeight = container.scrollHeight;
|
|
1090
|
-
|
|
1091
|
-
if (currentMessages.length > MSG_MAX_LOADED) {
|
|
1092
|
-
currentMessages = currentMessages.slice(0, MSG_MAX_LOADED);
|
|
1093
|
-
}
|
|
1116
|
+
setCurrentMessages([...data.messages, ...currentMessages].slice(0, MSG_MAX_LOADED));
|
|
1094
1117
|
renderMessages(currentMessages);
|
|
1095
1118
|
container.scrollTop = container.scrollHeight - prevHeight;
|
|
1096
1119
|
}
|
|
@@ -1675,10 +1698,9 @@ function rejectWaitingPlan(inputId) {
|
|
|
1675
1698
|
async function respondWaiting(payload) {
|
|
1676
1699
|
if (!currentSessionId || !currentWaiting?.id) return;
|
|
1677
1700
|
try {
|
|
1678
|
-
const res = await
|
|
1701
|
+
const res = await api(apiPath`/api/sessions/${currentSessionId}/waiting/respond`, {
|
|
1679
1702
|
method: 'POST',
|
|
1680
|
-
|
|
1681
|
-
body: JSON.stringify({ id: currentWaiting.id, ...payload }),
|
|
1703
|
+
body: { id: currentWaiting.id, ...payload },
|
|
1682
1704
|
});
|
|
1683
1705
|
if (res.ok || res.status === 409 || res.status === 410) {
|
|
1684
1706
|
clearWaitingUi();
|
|
@@ -1725,9 +1747,7 @@ function clearWaitingUi() {
|
|
|
1725
1747
|
async function discardWaiting() {
|
|
1726
1748
|
if (!currentSessionId) return;
|
|
1727
1749
|
try {
|
|
1728
|
-
const res = await
|
|
1729
|
-
method: 'POST',
|
|
1730
|
-
});
|
|
1750
|
+
const res = await api(apiPath`/api/sessions/${currentSessionId}/waiting/discard`, { method: 'POST' });
|
|
1731
1751
|
if (res.ok) clearWaitingUi();
|
|
1732
1752
|
} catch (e) {
|
|
1733
1753
|
console.error('[discardWaiting]', e);
|
|
@@ -1852,15 +1872,12 @@ function getPinId(m) {
|
|
|
1852
1872
|
}
|
|
1853
1873
|
|
|
1854
1874
|
function loadPins(sessionId) {
|
|
1855
|
-
|
|
1856
|
-
return JSON.parse(store.getItem(`pinned-messages-${sessionId}`)) || [];
|
|
1857
|
-
} catch {
|
|
1858
|
-
return [];
|
|
1859
|
-
}
|
|
1875
|
+
return readStoredList(pinsKey(sessionId));
|
|
1860
1876
|
}
|
|
1861
1877
|
|
|
1862
1878
|
function savePins(sessionId, pins) {
|
|
1863
|
-
store.
|
|
1879
|
+
if (pins.length) store.writeJson(pinsKey(sessionId), pins);
|
|
1880
|
+
else store.removeItem(pinsKey(sessionId));
|
|
1864
1881
|
}
|
|
1865
1882
|
|
|
1866
1883
|
function isPinned(m) {
|
|
@@ -1991,6 +2008,7 @@ function showPinnedMsgDetail(pinIdx) {
|
|
|
1991
2008
|
autoSizeModal(pinModal, body);
|
|
1992
2009
|
const pinBtn = document.getElementById('msg-detail-pin-btn');
|
|
1993
2010
|
if (pinBtn) pinBtn.classList.add('active');
|
|
2011
|
+
syncMsgDetailPaneBtn();
|
|
1994
2012
|
document.getElementById('msg-detail-modal').classList.add('visible');
|
|
1995
2013
|
mountReplyReview(pin, body, pinModal);
|
|
1996
2014
|
}
|
|
@@ -2026,34 +2044,22 @@ let stickySessionIds = new Set();
|
|
|
2026
2044
|
const deferredPinPlacement = new Set();
|
|
2027
2045
|
|
|
2028
2046
|
function loadPinnedSessions() {
|
|
2029
|
-
|
|
2030
|
-
return new Set(JSON.parse(store.getItem('pinned-sessions')) || []);
|
|
2031
|
-
} catch {
|
|
2032
|
-
return new Set();
|
|
2033
|
-
}
|
|
2047
|
+
return new Set(readStoredList(PINNED_SESSIONS_KEY));
|
|
2034
2048
|
}
|
|
2035
2049
|
|
|
2036
2050
|
function loadStickySessions() {
|
|
2037
|
-
|
|
2038
|
-
return new Set(JSON.parse(store.getItem('sticky-sessions')) || []);
|
|
2039
|
-
} catch {
|
|
2040
|
-
return new Set();
|
|
2041
|
-
}
|
|
2051
|
+
return new Set(readStoredList(STICKY_SESSIONS_KEY));
|
|
2042
2052
|
}
|
|
2043
2053
|
|
|
2044
2054
|
function savePinnedSessions() {
|
|
2045
|
-
store.
|
|
2046
|
-
store.
|
|
2055
|
+
store.writeJson(PINNED_SESSIONS_KEY, [...pinnedSessionIds]);
|
|
2056
|
+
store.writeJson(STICKY_SESSIONS_KEY, [...stickySessionIds]);
|
|
2047
2057
|
}
|
|
2048
2058
|
|
|
2049
2059
|
// Mirror pin state to server so it can be queried by the CLI. UI remains source of truth for itself.
|
|
2050
2060
|
function offloadSessionPin(sessionId) {
|
|
2051
2061
|
const state = getSessionPinState(sessionId);
|
|
2052
|
-
|
|
2053
|
-
method: 'POST',
|
|
2054
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2055
|
-
body: JSON.stringify({ id: sessionId, state }),
|
|
2056
|
-
}).catch(() => {});
|
|
2062
|
+
api('/api/session/pin', { method: 'POST', body: { id: sessionId, state } }).catch(() => {});
|
|
2057
2063
|
}
|
|
2058
2064
|
|
|
2059
2065
|
function clearSessionPin(sessionId) {
|
|
@@ -2236,33 +2242,27 @@ function highlightSelectedMsg() {
|
|
|
2236
2242
|
if (el) el.classList.add('selected');
|
|
2237
2243
|
}
|
|
2238
2244
|
|
|
2239
|
-
function
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
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) {
|
|
2247
2253
|
if (m.type === 'tool_use') {
|
|
2248
|
-
document.getElementById('msg-detail-title').textContent = m.tool;
|
|
2249
2254
|
const fullText = m.fullDetail || m.detail || '';
|
|
2250
2255
|
const descHtml =
|
|
2251
2256
|
m.description && m.description !== fullText
|
|
2252
2257
|
? `<div style="margin-bottom:8px;color:var(--text-secondary);font-size:0.85rem">${escapeHtml(m.description)}</div>`
|
|
2253
2258
|
: '';
|
|
2254
2259
|
let agentExtraHtml = '';
|
|
2255
|
-
const agentBtn = document.getElementById('msg-detail-agent-btn');
|
|
2256
2260
|
if (m.tool === 'Agent' && m.agentId) {
|
|
2257
2261
|
const agentRespText = m.agentLastMessage ? stripAnsi(m.agentLastMessage.trim()) : null;
|
|
2258
2262
|
const agentPromptText = m.agentPrompt || null;
|
|
2259
2263
|
const respHtml = agentRespText ? renderMarkdown(agentRespText) : null;
|
|
2260
2264
|
const promptHtml = agentPromptText ? renderMarkdown(agentPromptText) : null;
|
|
2261
2265
|
agentExtraHtml += renderAgentTabs(promptHtml, respHtml, agentPromptText, agentRespText);
|
|
2262
|
-
agentBtn.style.display = '';
|
|
2263
|
-
agentBtn.dataset.agentId = m.agentId;
|
|
2264
|
-
} else {
|
|
2265
|
-
agentBtn.style.display = 'none';
|
|
2266
2266
|
}
|
|
2267
2267
|
const sendProto = m.tool === 'SendMessage' && m.params?.protocol;
|
|
2268
2268
|
const isFindings = m.tool === 'ReportFindings';
|
|
@@ -2296,7 +2296,7 @@ function showMsgDetail(idx) {
|
|
|
2296
2296
|
}
|
|
2297
2297
|
const answersHtml = m.answerPayload ? renderAnswerPayloadHtml(m.answerPayload) : '';
|
|
2298
2298
|
const toolResultImagesHtml = renderToolResultImagesHtml(m.toolResultImageCount, m.toolUseId);
|
|
2299
|
-
|
|
2299
|
+
return (
|
|
2300
2300
|
mainHtml +
|
|
2301
2301
|
toolParamsHtml +
|
|
2302
2302
|
answersHtml +
|
|
@@ -2304,53 +2304,94 @@ function showMsgDetail(idx) {
|
|
|
2304
2304
|
findingsHtml +
|
|
2305
2305
|
(hasAgentTabs ? '' : toolResultHtml) +
|
|
2306
2306
|
toolResultImagesHtml +
|
|
2307
|
-
agentExtraHtml
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2307
|
+
agentExtraHtml
|
|
2308
|
+
);
|
|
2309
|
+
}
|
|
2310
|
+
if (m.type === 'teammate') {
|
|
2311
2311
|
if (m.isProtocol) {
|
|
2312
|
-
|
|
2312
|
+
return m.protocolData
|
|
2313
2313
|
? renderProtocolDetail(m.protocolData)
|
|
2314
2314
|
: `<div class="teammate-idle-detail"><span class="protocol-label">${escapeHtml(m.protocolLabel || m.protocolType)}</span></div>`;
|
|
2315
|
-
} else {
|
|
2316
|
-
const text = stripAnsi(m.fullText || m.text || '');
|
|
2317
|
-
body.innerHTML = renderMarkdown(text);
|
|
2318
2315
|
}
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
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;
|
|
2343
2386
|
const overlay = document.getElementById('msg-detail-modal');
|
|
2344
2387
|
const modal = overlay.querySelector('.modal');
|
|
2345
2388
|
autoSizeModal(modal, body);
|
|
2346
2389
|
setMsgDetailFollow(msgDetailFollowLatest);
|
|
2347
2390
|
|
|
2348
|
-
|
|
2349
|
-
if (m.model) meta.unshift(m.model);
|
|
2350
|
-
meta.push(`${idx + 1} of ${currentMessages.length}`);
|
|
2351
|
-
document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
|
|
2391
|
+
setMsgDetailMeta(m, position);
|
|
2352
2392
|
currentPinDetailId = null;
|
|
2353
2393
|
updateMsgDetailPinState();
|
|
2394
|
+
syncMsgDetailPaneBtn();
|
|
2354
2395
|
overlay.classList.add('visible');
|
|
2355
2396
|
mountReplyReview(m, body, modal);
|
|
2356
2397
|
}
|
|
@@ -2450,12 +2491,12 @@ function _applyModalFullscreen(modalId, on) {
|
|
|
2450
2491
|
function toggleModalFullscreen(modalId) {
|
|
2451
2492
|
const on = !_modalEl(modalId).classList.contains('fullscreen');
|
|
2452
2493
|
_applyModalFullscreen(modalId, on);
|
|
2453
|
-
store.setItem(
|
|
2494
|
+
store.setItem(modalFullscreenKey(modalId), String(on));
|
|
2454
2495
|
}
|
|
2455
2496
|
|
|
2456
2497
|
function loadModalFullscreen() {
|
|
2457
2498
|
_forEachFullscreenModal((modalId) => {
|
|
2458
|
-
if (store.getItem(
|
|
2499
|
+
if (store.getItem(modalFullscreenKey(modalId)) === 'true') _applyModalFullscreen(modalId, true);
|
|
2459
2500
|
});
|
|
2460
2501
|
}
|
|
2461
2502
|
|
|
@@ -2639,6 +2680,16 @@ function sendMessageAddress(peerName) {
|
|
|
2639
2680
|
return `SendMessage(${peerName})`;
|
|
2640
2681
|
}
|
|
2641
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
|
+
|
|
2642
2693
|
async function copyWithFeedback(text, btn) {
|
|
2643
2694
|
if (btn.dataset.copying) return;
|
|
2644
2695
|
try {
|
|
@@ -2652,6 +2703,7 @@ async function copyWithFeedback(text, btn) {
|
|
|
2652
2703
|
}, 1500);
|
|
2653
2704
|
} catch (e) {
|
|
2654
2705
|
console.error('Failed to copy:', e);
|
|
2706
|
+
showToast('Failed to copy', 'error');
|
|
2655
2707
|
}
|
|
2656
2708
|
}
|
|
2657
2709
|
|
|
@@ -2767,25 +2819,25 @@ function renderAnswerPayloadHtml(answerPayload) {
|
|
|
2767
2819
|
})
|
|
2768
2820
|
.join('');
|
|
2769
2821
|
return `<div style="margin-top:6px">
|
|
2770
|
-
<div
|
|
2822
|
+
<div class="tool-detail-label">${escapeHtml(q)}</div>
|
|
2771
2823
|
<ul style="margin:2px 0 0 16px;padding:0">${items}</ul>
|
|
2772
2824
|
</div>`;
|
|
2773
2825
|
})
|
|
2774
2826
|
.join('');
|
|
2775
|
-
return `<div
|
|
2776
|
-
<div
|
|
2827
|
+
return `<div class="tool-detail-section-wide">
|
|
2828
|
+
<div class="tool-detail-heading">Answers</div>
|
|
2777
2829
|
${rows}
|
|
2778
2830
|
</div>`;
|
|
2779
2831
|
}
|
|
2780
2832
|
|
|
2781
2833
|
//#region FINDINGS
|
|
2782
2834
|
const FINDING_VERDICT_COLORS = {
|
|
2783
|
-
CONFIRMED: 'var(--danger
|
|
2784
|
-
PLAUSIBLE: 'var(--warning
|
|
2835
|
+
CONFIRMED: 'var(--danger)',
|
|
2836
|
+
PLAUSIBLE: 'var(--warning)',
|
|
2785
2837
|
};
|
|
2786
2838
|
const FINDING_OUTCOME_COLORS = {
|
|
2787
|
-
fixed: 'var(--success
|
|
2788
|
-
no_change_needed: 'var(--info
|
|
2839
|
+
fixed: 'var(--success)',
|
|
2840
|
+
no_change_needed: 'var(--info)',
|
|
2789
2841
|
skipped: 'var(--text-muted)',
|
|
2790
2842
|
};
|
|
2791
2843
|
function findingBadge(label, color) {
|
|
@@ -2878,7 +2930,7 @@ function renderToolParamsHtml(params) {
|
|
|
2878
2930
|
blocks.push({ k, display, full });
|
|
2879
2931
|
} else {
|
|
2880
2932
|
badges.push(
|
|
2881
|
-
`<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>`,
|
|
2882
2934
|
);
|
|
2883
2935
|
}
|
|
2884
2936
|
}
|
|
@@ -2893,20 +2945,20 @@ function renderToolParamsHtml(params) {
|
|
|
2893
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>`;
|
|
2894
2946
|
}
|
|
2895
2947
|
for (const { k, pretty } of jsonBlocks) {
|
|
2896
|
-
html += `<div
|
|
2897
|
-
<div
|
|
2898
|
-
<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>
|
|
2899
2951
|
</div>`;
|
|
2900
2952
|
}
|
|
2901
2953
|
if (params.old_string || params.new_string) {
|
|
2902
|
-
html += `<div
|
|
2954
|
+
html += `<div class="tool-detail-section">`;
|
|
2903
2955
|
if (params.old_string) {
|
|
2904
|
-
html += `<div
|
|
2905
|
-
<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>`;
|
|
2906
2958
|
}
|
|
2907
2959
|
if (params.new_string) {
|
|
2908
|
-
html += `<div
|
|
2909
|
-
<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>`;
|
|
2910
2962
|
}
|
|
2911
2963
|
html += `</div>`;
|
|
2912
2964
|
}
|
|
@@ -2916,29 +2968,32 @@ function renderToolParamsHtml(params) {
|
|
|
2916
2968
|
// small writes render as before.
|
|
2917
2969
|
const fullContent = params.contentFull || params.content;
|
|
2918
2970
|
const isTruncated = !!params.contentFull || params.content.length > CONTENT_TRUNCATE_MAX;
|
|
2919
|
-
const
|
|
2920
|
-
|
|
2921
|
-
|
|
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) : '');
|
|
2922
2976
|
let writeMoreBtn = '',
|
|
2923
2977
|
fullBlock = '';
|
|
2924
2978
|
if (isTruncated) {
|
|
2925
|
-
const toggle = makeExpandToggle(
|
|
2979
|
+
const toggle = makeExpandToggle(truncHtml, escapeHtml(fullContent), {
|
|
2926
2980
|
fontSize: '0.75rem',
|
|
2927
2981
|
maxHeight: '500px',
|
|
2928
2982
|
tinted: true,
|
|
2983
|
+
highlightLang: lang,
|
|
2929
2984
|
});
|
|
2930
2985
|
writeMoreBtn = ` ${toggle.btn}`;
|
|
2931
2986
|
fullBlock = toggle.full;
|
|
2932
2987
|
}
|
|
2933
|
-
html += `<div
|
|
2934
|
-
<div
|
|
2935
|
-
<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>
|
|
2936
2991
|
${fullBlock}
|
|
2937
2992
|
</div>`;
|
|
2938
2993
|
}
|
|
2939
2994
|
if (params.plan) {
|
|
2940
|
-
html += `<div
|
|
2941
|
-
<div
|
|
2995
|
+
html += `<div class="tool-detail-section">
|
|
2996
|
+
<div class="tool-detail-label" style="margin-bottom:4px">Plan</div>
|
|
2942
2997
|
<div class="markdown-body">${renderMarkdown(params.plan)}</div>
|
|
2943
2998
|
</div>`;
|
|
2944
2999
|
}
|
|
@@ -2971,20 +3026,22 @@ function toolOutputHtml(text) {
|
|
|
2971
3026
|
return sanitizeOutputHtml(text);
|
|
2972
3027
|
}
|
|
2973
3028
|
|
|
3029
|
+
// Strings and comments share one pass, so the apostrophe in "# don't" does not open a string.
|
|
2974
3030
|
function highlightBash(escaped) {
|
|
2975
3031
|
return escaped
|
|
2976
|
-
.replace(
|
|
2977
|
-
|
|
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
|
+
)
|
|
2978
3035
|
.replace(
|
|
2979
3036
|
/\b(if|then|else|elif|fi|for|do|done|while|until|case|esac|function|return|in|select)\b/g,
|
|
2980
|
-
'<span
|
|
3037
|
+
'<span class="hljs-keyword">$1</span>',
|
|
2981
3038
|
)
|
|
2982
3039
|
.replace(
|
|
2983
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,
|
|
2984
|
-
'<span
|
|
3041
|
+
'<span class="hljs-built_in">$1</span>',
|
|
2985
3042
|
)
|
|
2986
|
-
.replace(/(\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*)/g, '<span
|
|
2987
|
-
.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>');
|
|
2988
3045
|
}
|
|
2989
3046
|
|
|
2990
3047
|
const TINTED_PRE_CLASS = 'msg-detail-pre msg-detail-pre-tinted';
|
|
@@ -2992,6 +3049,12 @@ let _expandIdCounter = 0;
|
|
|
2992
3049
|
function _applyExpandToggle(btn, fullEl) {
|
|
2993
3050
|
const truncEl = btn.parentElement.nextElementSibling;
|
|
2994
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
|
+
}
|
|
2995
3058
|
fullEl.style.display = expand ? 'block' : 'none';
|
|
2996
3059
|
if (truncEl) truncEl.style.display = expand ? 'none' : 'block';
|
|
2997
3060
|
btn.textContent = expand ? 'Show less' : 'Show more';
|
|
@@ -3012,7 +3075,8 @@ function makeExpandToggle(_truncatedHtml, fullHtml, opts = {}) {
|
|
|
3012
3075
|
const cls = opts.tinted ? TINTED_PRE_CLASS : 'msg-detail-pre';
|
|
3013
3076
|
const btn = `<button data-expand-id="${id}" onclick="_toggleExpand(this)" class="expand-toggle-btn" style="font-size:${fontSize}">Show more</button>`;
|
|
3014
3077
|
const mhStyle = maxHeight ? `max-height:${maxHeight};` : '';
|
|
3015
|
-
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>`;
|
|
3016
3080
|
return { btn, full };
|
|
3017
3081
|
}
|
|
3018
3082
|
|
|
@@ -3051,8 +3115,8 @@ function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
|
|
|
3051
3115
|
} else if (isTruncated) {
|
|
3052
3116
|
truncLabel = '<span style="color:var(--text-muted);font-size:0.8rem;margin-left:6px">(truncated)</span>';
|
|
3053
3117
|
}
|
|
3054
|
-
return `<div
|
|
3055
|
-
<div
|
|
3118
|
+
return `<div class="tool-detail-section-wide">
|
|
3119
|
+
<div class="tool-detail-heading">Output${truncLabel}</div>
|
|
3056
3120
|
<pre class="msg-detail-pre" style="overflow:auto">${escaped}</pre>
|
|
3057
3121
|
${fullBlock}
|
|
3058
3122
|
</div>`;
|
|
@@ -3078,9 +3142,7 @@ async function _toggleToolResultExpand(btn) {
|
|
|
3078
3142
|
btn.disabled = true;
|
|
3079
3143
|
btn.textContent = 'Loading…';
|
|
3080
3144
|
try {
|
|
3081
|
-
const r = await
|
|
3082
|
-
`/api/sessions/${encodeURIComponent(currentSessionId)}/tool-result/${encodeURIComponent(btn.dataset.toolUseId)}`,
|
|
3083
|
-
);
|
|
3145
|
+
const r = await api(apiPath`/api/sessions/${currentSessionId}/tool-result/${btn.dataset.toolUseId}`);
|
|
3084
3146
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
3085
3147
|
const { content } = await r.json();
|
|
3086
3148
|
f.innerHTML = toolOutputHtml(content);
|
|
@@ -3088,7 +3150,7 @@ async function _toggleToolResultExpand(btn) {
|
|
|
3088
3150
|
} catch (_e) {
|
|
3089
3151
|
btn.textContent = 'Show more';
|
|
3090
3152
|
btn.disabled = false;
|
|
3091
|
-
showToast('Failed to load full output');
|
|
3153
|
+
showToast('Failed to load full output', 'error');
|
|
3092
3154
|
return;
|
|
3093
3155
|
}
|
|
3094
3156
|
btn.disabled = false;
|
|
@@ -3121,14 +3183,10 @@ async function copyMsgToClipboard(btn) {
|
|
|
3121
3183
|
|
|
3122
3184
|
async function postAndToast(url, body, label) {
|
|
3123
3185
|
try {
|
|
3124
|
-
const r = await
|
|
3125
|
-
|
|
3126
|
-
headers: { 'Content-Type': 'application/json' },
|
|
3127
|
-
body: JSON.stringify(body),
|
|
3128
|
-
});
|
|
3129
|
-
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');
|
|
3130
3188
|
} catch (_e) {
|
|
3131
|
-
showToast(`Failed to open ${label}
|
|
3189
|
+
showToast(`Failed to open ${label}`, 'error');
|
|
3132
3190
|
}
|
|
3133
3191
|
}
|
|
3134
3192
|
|
|
@@ -3341,7 +3399,7 @@ function togglePinFromAgentModal() {
|
|
|
3341
3399
|
async function dismissAgent(agentId) {
|
|
3342
3400
|
if (!currentSessionId || !agentId) return;
|
|
3343
3401
|
try {
|
|
3344
|
-
const res = await
|
|
3402
|
+
const res = await api(apiPath`/api/sessions/${currentSessionId}/agents/${agentId}/stop`, { method: 'POST' });
|
|
3345
3403
|
if (res.ok) {
|
|
3346
3404
|
currentWaiting = null;
|
|
3347
3405
|
fetchAgents(currentSessionId);
|
|
@@ -3427,14 +3485,6 @@ function showAgentModal(agentId) {
|
|
|
3427
3485
|
const dismissBtn = document.getElementById('agent-modal-dismiss-btn');
|
|
3428
3486
|
dismissBtn.style.display = agent.status === 'active' || agent.status === 'idle' ? '' : 'none';
|
|
3429
3487
|
modal.classList.add('visible');
|
|
3430
|
-
const keyHandler = (e) => {
|
|
3431
|
-
if (e.key === 'Escape') {
|
|
3432
|
-
e.preventDefault();
|
|
3433
|
-
closeAgentModal();
|
|
3434
|
-
document.removeEventListener('keydown', keyHandler);
|
|
3435
|
-
}
|
|
3436
|
-
};
|
|
3437
|
-
document.addEventListener('keydown', keyHandler);
|
|
3438
3488
|
}
|
|
3439
3489
|
|
|
3440
3490
|
function closeAgentModal() {
|
|
@@ -3607,9 +3657,7 @@ function getFilteredSessions() {
|
|
|
3607
3657
|
|
|
3608
3658
|
function saveOrder(key, prev, next) {
|
|
3609
3659
|
if (next.length === prev.length && next.every((id, i) => id === prev[i])) return prev;
|
|
3610
|
-
|
|
3611
|
-
store.setItem(key, JSON.stringify(next));
|
|
3612
|
-
} catch (_) {}
|
|
3660
|
+
store.writeJson(key, next);
|
|
3613
3661
|
return next;
|
|
3614
3662
|
}
|
|
3615
3663
|
|
|
@@ -3804,7 +3852,7 @@ function renderSessions() {
|
|
|
3804
3852
|
return;
|
|
3805
3853
|
}
|
|
3806
3854
|
|
|
3807
|
-
const groupPinned = store.getItem(
|
|
3855
|
+
const groupPinned = store.getItem(GROUP_PINNED_KEY) !== 'false';
|
|
3808
3856
|
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
|
|
3809
3857
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
3810
3858
|
const renderGroupSessions = (sessions, pinKey) => {
|
|
@@ -4351,11 +4399,7 @@ function startAddTask(tile) {
|
|
|
4351
4399
|
input.disabled = true;
|
|
4352
4400
|
const sessionId = currentSessionId;
|
|
4353
4401
|
try {
|
|
4354
|
-
const res = await
|
|
4355
|
-
method: 'POST',
|
|
4356
|
-
headers: { 'Content-Type': 'application/json' },
|
|
4357
|
-
body: JSON.stringify({ subject }),
|
|
4358
|
-
});
|
|
4402
|
+
const res = await api(apiPath`/api/tasks/${sessionId}`, { method: 'POST', body: { subject } });
|
|
4359
4403
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
4360
4404
|
// The response carries the finished task, and the watcher will resend it within the
|
|
4361
4405
|
// SSE debounce anyway -- so show it now rather than paying a session refetch for it.
|
|
@@ -4447,11 +4491,7 @@ async function onColumnDrop(e) {
|
|
|
4447
4491
|
task.status = newStatus;
|
|
4448
4492
|
renderKanban();
|
|
4449
4493
|
try {
|
|
4450
|
-
const res = await
|
|
4451
|
-
method: 'PUT',
|
|
4452
|
-
headers: { 'Content-Type': 'application/json' },
|
|
4453
|
-
body: JSON.stringify({ status: newStatus }),
|
|
4454
|
-
});
|
|
4494
|
+
const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'PUT', body: { status: newStatus } });
|
|
4455
4495
|
if (res.ok) return;
|
|
4456
4496
|
} catch (_) {}
|
|
4457
4497
|
if (task.status === newStatus) {
|
|
@@ -4488,34 +4528,28 @@ function pinKey(projectPath) {
|
|
|
4488
4528
|
}
|
|
4489
4529
|
|
|
4490
4530
|
function loadSessionGroups() {
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
} catch (_) {
|
|
4510
|
-
sessionGroups = [];
|
|
4511
|
-
sgReleased = new Set();
|
|
4512
|
-
}
|
|
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') : []);
|
|
4513
4549
|
}
|
|
4514
4550
|
|
|
4515
4551
|
function persistSessionGroups() {
|
|
4516
|
-
|
|
4517
|
-
store.setItem(SESSION_GROUPS_KEY, JSON.stringify({ version: 1, groups: sessionGroups, released: [...sgReleased] }));
|
|
4518
|
-
} catch (_) {}
|
|
4552
|
+
store.writeJson(SESSION_GROUPS_KEY, { version: 1, groups: sessionGroups, released: [...sgReleased] });
|
|
4519
4553
|
}
|
|
4520
4554
|
|
|
4521
4555
|
function sgGroupById(id) {
|
|
@@ -5030,10 +5064,13 @@ function initSessionGroupsDnd() {
|
|
|
5030
5064
|
// Non-drag path: right-click or long-press a session / project header.
|
|
5031
5065
|
let sgLongPressTimer = null;
|
|
5032
5066
|
|
|
5033
|
-
function sgCloseMenu() {
|
|
5067
|
+
function sgCloseMenu(restore) {
|
|
5034
5068
|
document.removeEventListener('keydown', sgMenuKeydown, true);
|
|
5035
5069
|
const menu = document.getElementById('sg-menu');
|
|
5036
|
-
if (menu)
|
|
5070
|
+
if (menu) {
|
|
5071
|
+
menu.remove();
|
|
5072
|
+
takeMenuFocus(restore);
|
|
5073
|
+
}
|
|
5037
5074
|
}
|
|
5038
5075
|
|
|
5039
5076
|
// Run in the capture phase: the global handler would otherwise read these keys as sidebar shortcuts.
|
|
@@ -5047,11 +5084,25 @@ function menuKeydown(e, items, close) {
|
|
|
5047
5084
|
else if (matchKey(e, 'ArrowUp')) next = i < 0 ? items.length - 1 : (i - 1 + items.length) % items.length;
|
|
5048
5085
|
else if (e.key === 'Home') next = 0;
|
|
5049
5086
|
else if (e.key === 'End') next = items.length - 1;
|
|
5050
|
-
else if (e.key === 'Escape' || e.key === 'Tab') close();
|
|
5087
|
+
else if (e.key === 'Escape' || e.key === 'Tab') close(true);
|
|
5051
5088
|
if (next !== null) items[next]?.focus();
|
|
5052
5089
|
}
|
|
5053
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
|
+
|
|
5054
5104
|
function placeMenu(menu, x, y) {
|
|
5105
|
+
menuReturnFocus = document.activeElement;
|
|
5055
5106
|
document.body.appendChild(menu);
|
|
5056
5107
|
const rect = menu.getBoundingClientRect();
|
|
5057
5108
|
menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`;
|
|
@@ -5135,7 +5186,7 @@ function sgOnTouchStart(e) {
|
|
|
5135
5186
|
document.addEventListener('click', (e) => {
|
|
5136
5187
|
const item = e.target.closest('.sg-menu-item');
|
|
5137
5188
|
if (!item) {
|
|
5138
|
-
if (!e.target.closest('.sg-menu')) sgCloseMenu();
|
|
5189
|
+
if (!e.target.closest('.sg-menu')) sgCloseMenu(true);
|
|
5139
5190
|
return;
|
|
5140
5191
|
}
|
|
5141
5192
|
e.stopPropagation();
|
|
@@ -5359,9 +5410,7 @@ function setGroupCollapsed(header, collapsed) {
|
|
|
5359
5410
|
}
|
|
5360
5411
|
|
|
5361
5412
|
function persistCollapsedGroups() {
|
|
5362
|
-
|
|
5363
|
-
store.setItem('collapsedGroups', JSON.stringify([...collapsedProjectGroups]));
|
|
5364
|
-
} catch (_) {}
|
|
5413
|
+
store.writeJson(COLLAPSED_GROUPS_KEY, [...collapsedProjectGroups]);
|
|
5365
5414
|
}
|
|
5366
5415
|
|
|
5367
5416
|
let prevActiveSessionIds = null; // null until primed by the first onlyNew pass
|
|
@@ -5548,42 +5597,6 @@ sessionsList.addEventListener('click', (e) => {
|
|
|
5548
5597
|
if (focusZone === 'sidebar' || (!taskInUse && !terminalPaneFocused())) setFocusZone('sidebar', el);
|
|
5549
5598
|
});
|
|
5550
5599
|
|
|
5551
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5552
|
-
function getAvailableTasksOptions(currentTaskId = null) {
|
|
5553
|
-
const pending = currentTasks.filter((t) => t.status === 'pending' && t.id !== currentTaskId);
|
|
5554
|
-
const inProgress = currentTasks.filter((t) => t.status === 'in_progress' && t.id !== currentTaskId);
|
|
5555
|
-
const completed = currentTasks.filter((t) => t.status === 'completed' && t.id !== currentTaskId);
|
|
5556
|
-
|
|
5557
|
-
// Build options grouped by status
|
|
5558
|
-
let options = '';
|
|
5559
|
-
|
|
5560
|
-
if (pending.length > 0) {
|
|
5561
|
-
options += '<optgroup label="Pending">';
|
|
5562
|
-
pending.forEach((t, _idx) => {
|
|
5563
|
-
options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
|
|
5564
|
-
});
|
|
5565
|
-
options += '</optgroup>';
|
|
5566
|
-
}
|
|
5567
|
-
|
|
5568
|
-
if (inProgress.length > 0) {
|
|
5569
|
-
options += '<optgroup label="In Progress">';
|
|
5570
|
-
inProgress.forEach((t, _idx) => {
|
|
5571
|
-
options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
|
|
5572
|
-
});
|
|
5573
|
-
options += '</optgroup>';
|
|
5574
|
-
}
|
|
5575
|
-
|
|
5576
|
-
if (completed.length > 0) {
|
|
5577
|
-
options += '<optgroup label="Completed">';
|
|
5578
|
-
completed.forEach((t, _idx) => {
|
|
5579
|
-
options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
|
|
5580
|
-
});
|
|
5581
|
-
options += '</optgroup>';
|
|
5582
|
-
}
|
|
5583
|
-
|
|
5584
|
-
return options;
|
|
5585
|
-
}
|
|
5586
|
-
|
|
5587
5600
|
//#endregion
|
|
5588
5601
|
|
|
5589
5602
|
//#region TASK_DETAIL
|
|
@@ -5594,15 +5607,9 @@ async function showTaskDetail(taskId, sessionId = null) {
|
|
|
5594
5607
|
|
|
5595
5608
|
// If task not found in currentTasks, fetch it from the session
|
|
5596
5609
|
if (!task && sessionId && sessionId !== 'undefined') {
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
task = tasks.find((t) => t.id === taskId);
|
|
5601
|
-
if (!task) return;
|
|
5602
|
-
} catch (error) {
|
|
5603
|
-
console.error('Failed to fetch task:', error);
|
|
5604
|
-
return;
|
|
5605
|
-
}
|
|
5610
|
+
const tasks = await getJson(apiPath`/api/sessions/${sessionId}`, []);
|
|
5611
|
+
task = tasks.find((t) => t.id === taskId);
|
|
5612
|
+
if (!task) return;
|
|
5606
5613
|
}
|
|
5607
5614
|
|
|
5608
5615
|
if (!task) return;
|
|
@@ -5782,11 +5789,7 @@ function editDescription(descEl, task, sessionId) {
|
|
|
5782
5789
|
|
|
5783
5790
|
async function saveTaskField(taskId, sessionId, field, value) {
|
|
5784
5791
|
try {
|
|
5785
|
-
const res = await
|
|
5786
|
-
method: 'PUT',
|
|
5787
|
-
headers: { 'Content-Type': 'application/json' },
|
|
5788
|
-
body: JSON.stringify({ [field]: value }),
|
|
5789
|
-
});
|
|
5792
|
+
const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'PUT', body: { [field]: value } });
|
|
5790
5793
|
|
|
5791
5794
|
if (res.ok) {
|
|
5792
5795
|
lastCurrentTasksHash = null;
|
|
@@ -5805,47 +5808,6 @@ function closeDetailPanel() {
|
|
|
5805
5808
|
taskHighlightDimmed = false;
|
|
5806
5809
|
}
|
|
5807
5810
|
|
|
5808
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5809
|
-
function showBlockedTaskModal(task) {
|
|
5810
|
-
const messageDiv = document.getElementById('blocked-task-message');
|
|
5811
|
-
|
|
5812
|
-
const blockedByList = task.blockedBy
|
|
5813
|
-
.map((id) => {
|
|
5814
|
-
const blockingTask = currentTasks.find((t) => t.id === id);
|
|
5815
|
-
if (blockingTask) {
|
|
5816
|
-
return `<li><strong>#${blockingTask.id}</strong> - ${escapeHtml(blockingTask.subject)}</li>`;
|
|
5817
|
-
}
|
|
5818
|
-
return `<li><strong>#${id}</strong></li>`;
|
|
5819
|
-
})
|
|
5820
|
-
.join('');
|
|
5821
|
-
|
|
5822
|
-
messageDiv.innerHTML = `
|
|
5823
|
-
<p style="margin-bottom: 12px;">Task <strong>#${task.id}</strong> - ${escapeHtml(task.subject)} is currently blocked by:</p>
|
|
5824
|
-
<ul style="margin: 0 0 16px 20px; padding: 0;">${blockedByList}</ul>
|
|
5825
|
-
<p style="margin: 0; color: var(--text-secondary); font-size: 13px;">
|
|
5826
|
-
Please resolve these dependencies before moving this task to <strong>In Progress</strong>.
|
|
5827
|
-
</p>
|
|
5828
|
-
`;
|
|
5829
|
-
|
|
5830
|
-
const modal = document.getElementById('blocked-task-modal');
|
|
5831
|
-
modal.classList.add('visible');
|
|
5832
|
-
|
|
5833
|
-
// Handle ESC key
|
|
5834
|
-
const keyHandler = (e) => {
|
|
5835
|
-
if (e.key === 'Escape') {
|
|
5836
|
-
e.preventDefault();
|
|
5837
|
-
closeBlockedTaskModal();
|
|
5838
|
-
document.removeEventListener('keydown', keyHandler);
|
|
5839
|
-
}
|
|
5840
|
-
};
|
|
5841
|
-
document.addEventListener('keydown', keyHandler);
|
|
5842
|
-
}
|
|
5843
|
-
|
|
5844
|
-
function closeBlockedTaskModal() {
|
|
5845
|
-
const modal = document.getElementById('blocked-task-modal');
|
|
5846
|
-
modal.classList.remove('visible');
|
|
5847
|
-
}
|
|
5848
|
-
|
|
5849
5811
|
//#endregion
|
|
5850
5812
|
|
|
5851
5813
|
//#region DELETE_TASK
|
|
@@ -5858,6 +5820,7 @@ function confirmModal({ title, message, okLabel }) {
|
|
|
5858
5820
|
document.getElementById('confirm-modal-message').textContent = message;
|
|
5859
5821
|
const buttons = [document.getElementById('confirm-cancel-btn'), document.getElementById('confirm-ok-btn')];
|
|
5860
5822
|
buttons[1].textContent = okLabel;
|
|
5823
|
+
const returnFocus = document.activeElement;
|
|
5861
5824
|
modal.classList.add('visible');
|
|
5862
5825
|
buttons[1].focus();
|
|
5863
5826
|
|
|
@@ -5876,6 +5839,7 @@ function confirmModal({ title, message, okLabel }) {
|
|
|
5876
5839
|
modal.classList.remove('visible');
|
|
5877
5840
|
document.removeEventListener('keydown', onKey);
|
|
5878
5841
|
closeConfirmModal = null;
|
|
5842
|
+
if (!ok) restoreFocusTo(returnFocus, modal);
|
|
5879
5843
|
resolve(ok);
|
|
5880
5844
|
};
|
|
5881
5845
|
});
|
|
@@ -5892,20 +5856,18 @@ async function deleteTask(taskId, sessionId) {
|
|
|
5892
5856
|
if (!ok) return;
|
|
5893
5857
|
|
|
5894
5858
|
try {
|
|
5895
|
-
const res = await
|
|
5896
|
-
method: 'DELETE',
|
|
5897
|
-
});
|
|
5859
|
+
const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'DELETE' });
|
|
5898
5860
|
|
|
5899
5861
|
if (res.ok) {
|
|
5900
5862
|
closeDetailPanel();
|
|
5901
5863
|
await refreshCurrentView();
|
|
5902
5864
|
} else {
|
|
5903
|
-
const error = await res.json();
|
|
5904
|
-
|
|
5865
|
+
const error = await res.json().catch(() => ({}));
|
|
5866
|
+
showToast(`Failed to delete task: ${error.error || 'Unknown error'}`, 'error');
|
|
5905
5867
|
}
|
|
5906
5868
|
} catch (error) {
|
|
5907
5869
|
console.error('Failed to delete task:', error);
|
|
5908
|
-
|
|
5870
|
+
showToast('Failed to delete task', 'error');
|
|
5909
5871
|
}
|
|
5910
5872
|
}
|
|
5911
5873
|
|
|
@@ -5942,14 +5904,20 @@ const SHORTCUT_TABS = [
|
|
|
5942
5904
|
],
|
|
5943
5905
|
},
|
|
5944
5906
|
{
|
|
5945
|
-
title: '
|
|
5907
|
+
title: 'Tabs',
|
|
5946
5908
|
rows: [
|
|
5947
|
-
{ keys: ['\\'], label: 'Toggle sidebar' },
|
|
5948
5909
|
{ keys: ['[', ']'], label: 'Previous / next tab (a split counts as one)' },
|
|
5949
5910
|
{ keys: ['Alt', '[ / ]'], combo: true, label: 'Change the tab in the focused side of a split' },
|
|
5950
5911
|
{ keys: ['|'], label: 'Split with the tab before it / unsplit (or drag a tab onto a side)' },
|
|
5951
5912
|
{ keys: ['{', '}'], label: 'Focus left / right side of a split' },
|
|
5952
|
-
{ 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' },
|
|
5953
5921
|
{ keys: ['T'], label: 'Toggle theme' },
|
|
5954
5922
|
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
5955
5923
|
{ keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
@@ -5959,13 +5927,6 @@ const SHORTCUT_TABS = [
|
|
|
5959
5927
|
{ keys: ['?'], label: 'Show this help' },
|
|
5960
5928
|
],
|
|
5961
5929
|
},
|
|
5962
|
-
{
|
|
5963
|
-
title: 'Copy',
|
|
5964
|
-
rows: [
|
|
5965
|
-
{ keys: ['Shift', 'C'], combo: true, label: 'Session id' },
|
|
5966
|
-
{ keys: ['Ctrl', 'Shift', 'C'], combo: true, label: 'Session name (SendMessage address)' },
|
|
5967
|
-
],
|
|
5968
|
-
},
|
|
5969
5930
|
],
|
|
5970
5931
|
},
|
|
5971
5932
|
{
|
|
@@ -5980,7 +5941,7 @@ const SHORTCUT_TABS = [
|
|
|
5980
5941
|
{ keys: ['I'], label: 'Session info' },
|
|
5981
5942
|
{ keys: ['.'], label: 'Pin / unpin' },
|
|
5982
5943
|
{ keys: ['>'], label: 'Toggle sticky' },
|
|
5983
|
-
{ keys: ['Shift', 'F10'], combo: true, label: '
|
|
5944
|
+
{ keys: ['Shift', 'F10'], combo: true, label: 'Session menu: move to group, fork (or Menu key)' },
|
|
5984
5945
|
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
|
|
5985
5946
|
{ keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
5986
5947
|
{ keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
|
|
@@ -5994,6 +5955,8 @@ const SHORTCUT_TABS = [
|
|
|
5994
5955
|
{ keys: ['Ctrl', 'Alt', 'N'], combo: true, hubMod: true, label: 'New session' },
|
|
5995
5956
|
{ keys: ['Ctrl', 'Alt', 'R'], combo: true, hubMod: true, label: 'Resume session (claude -r)' },
|
|
5996
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)' },
|
|
5997
5960
|
],
|
|
5998
5961
|
},
|
|
5999
5962
|
],
|
|
@@ -6006,7 +5969,7 @@ const SHORTCUT_TABS = [
|
|
|
6006
5969
|
rows: [
|
|
6007
5970
|
{ keys: ['Ctrl', '`'], combo: true, label: 'Show / hide terminal' },
|
|
6008
5971
|
{ keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
|
|
6009
|
-
{ keys: ['Alt', 'Shift', '`'], combo: true, label: 'End
|
|
5972
|
+
{ keys: ['Alt', 'Shift', '`'], combo: true, label: 'End terminal' },
|
|
6010
5973
|
{ keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
|
|
6011
5974
|
{ keys: ['Ctrl', '+/−/0'], combo: true, label: 'Terminal text size' },
|
|
6012
5975
|
],
|
|
@@ -6020,9 +5983,9 @@ const SHORTCUT_TABS = [
|
|
|
6020
5983
|
{
|
|
6021
5984
|
title: 'Hub',
|
|
6022
5985
|
rows: [
|
|
6023
|
-
{ keys: ['M'], label: 'Jump to marketplace' },
|
|
6024
|
-
{ keys: ['$'], label: 'Jump to cost' },
|
|
6025
|
-
{ 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' },
|
|
6026
5989
|
{
|
|
6027
5990
|
keys: ['Ctrl', 'Alt', '←/→'],
|
|
6028
5991
|
action: ['hub.prevApp', 'hub.nextApp'],
|
|
@@ -6104,8 +6067,14 @@ function buildHelpShortcuts(groups) {
|
|
|
6104
6067
|
let helpTab = 0;
|
|
6105
6068
|
const helpFilter = document.getElementById('help-filter');
|
|
6106
6069
|
|
|
6107
|
-
// Hub keys do nothing outside Claude Code Hub,
|
|
6108
|
-
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);
|
|
6109
6078
|
|
|
6110
6079
|
// The matching rows of every tab, in their groups.
|
|
6111
6080
|
function filteredShortcutGroups(query) {
|
|
@@ -6113,7 +6082,7 @@ function filteredShortcutGroups(query) {
|
|
|
6113
6082
|
.flatMap((t) => t.groups)
|
|
6114
6083
|
.map((g) => ({
|
|
6115
6084
|
title: g.title,
|
|
6116
|
-
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)),
|
|
6117
6086
|
}))
|
|
6118
6087
|
.filter((g) => g.rows.length);
|
|
6119
6088
|
}
|
|
@@ -6494,7 +6463,7 @@ function _updateStorageTotal() {
|
|
|
6494
6463
|
let _storageKnownSessions = null;
|
|
6495
6464
|
|
|
6496
6465
|
async function _fetchKnownSessions() {
|
|
6497
|
-
const res = await
|
|
6466
|
+
const res = await api('/api/sessions/known');
|
|
6498
6467
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
6499
6468
|
const known = new Map((await res.json()).map((s) => [s.id, s]));
|
|
6500
6469
|
for (const s of sessions) known.set(s.id, s);
|
|
@@ -6587,12 +6556,10 @@ function _renderStorageSessions() {
|
|
|
6587
6556
|
|
|
6588
6557
|
const msgMap = new Map();
|
|
6589
6558
|
for (const key of store.keys()) {
|
|
6590
|
-
if (!key.startsWith(
|
|
6591
|
-
const sid = key.slice(
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
if (pins.length) msgMap.set(sid, { pins, key });
|
|
6595
|
-
} 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 });
|
|
6596
6563
|
}
|
|
6597
6564
|
|
|
6598
6565
|
const allIds = [...new Set([...pinnedIds, ...msgMap.keys()])];
|
|
@@ -6672,7 +6639,7 @@ function _storageUnpinSession(id) {
|
|
|
6672
6639
|
}
|
|
6673
6640
|
|
|
6674
6641
|
function _storageClearSessionPins(sessionId) {
|
|
6675
|
-
store.removeItem(
|
|
6642
|
+
store.removeItem(pinsKey(sessionId));
|
|
6676
6643
|
if (currentSessionId === sessionId) {
|
|
6677
6644
|
currentPins = [];
|
|
6678
6645
|
const el = document.getElementById('message-panel-pinned');
|
|
@@ -6683,20 +6650,16 @@ function _storageClearSessionPins(sessionId) {
|
|
|
6683
6650
|
}
|
|
6684
6651
|
|
|
6685
6652
|
function _storageUnpinMessage(sessionId, pinId) {
|
|
6686
|
-
const
|
|
6687
|
-
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
|
|
6692
|
-
|
|
6693
|
-
|
|
6694
|
-
if (
|
|
6695
|
-
|
|
6696
|
-
const el = document.getElementById('message-panel-pinned');
|
|
6697
|
-
if (el) el.innerHTML = renderPinnedSection();
|
|
6698
|
-
}
|
|
6699
|
-
} 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
|
+
}
|
|
6700
6663
|
_renderStorageTab();
|
|
6701
6664
|
_updateStorageTotal();
|
|
6702
6665
|
}
|
|
@@ -6796,19 +6759,14 @@ function _storagePreviewScratchpad(key) {
|
|
|
6796
6759
|
|
|
6797
6760
|
function _storagePreviewPin(sessionId, pinId) {
|
|
6798
6761
|
closeStorageManager();
|
|
6799
|
-
const
|
|
6800
|
-
|
|
6801
|
-
|
|
6802
|
-
|
|
6803
|
-
|
|
6804
|
-
|
|
6805
|
-
|
|
6806
|
-
|
|
6807
|
-
_renderPinToDetail(pin);
|
|
6808
|
-
document.getElementById('msg-detail-modal').classList.add('visible');
|
|
6809
|
-
} catch (e) {
|
|
6810
|
-
console.error('_storagePreviewPin error:', e);
|
|
6811
|
-
}
|
|
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');
|
|
6812
6770
|
}
|
|
6813
6771
|
|
|
6814
6772
|
function _storageDeleteScratchpad(key) {
|
|
@@ -6901,7 +6859,7 @@ function _findOrphanedKeys(known) {
|
|
|
6901
6859
|
const orphaned = [];
|
|
6902
6860
|
for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
|
|
6903
6861
|
for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
|
|
6904
|
-
const sessionKeyPrefixes = [
|
|
6862
|
+
const sessionKeyPrefixes = [PINNED_MESSAGES_PREFIX, PAD_EMOJI_PREFIX, PREVIEW_STORAGE_PREFIX, PAD_LINKED_PREFIX];
|
|
6905
6863
|
for (const key of store.keys()) {
|
|
6906
6864
|
const pad = _parsePadKey(key);
|
|
6907
6865
|
if (pad?.kind === 'group') {
|
|
@@ -6928,23 +6886,28 @@ async function cleanupOrphanedStorage() {
|
|
|
6928
6886
|
try {
|
|
6929
6887
|
known = await _fetchKnownSessions();
|
|
6930
6888
|
} catch {
|
|
6931
|
-
showToast('
|
|
6889
|
+
showToast('Failed to list sessions — nothing was cleaned', 'error');
|
|
6932
6890
|
return;
|
|
6933
6891
|
}
|
|
6934
6892
|
const orphaned = _findOrphanedKeys(known);
|
|
6935
|
-
|
|
6893
|
+
const unpinned = new Set();
|
|
6936
6894
|
for (const key of orphaned) {
|
|
6937
6895
|
if (key.startsWith('__pinned__')) {
|
|
6938
|
-
|
|
6939
|
-
|
|
6896
|
+
const id = key.slice('__pinned__'.length);
|
|
6897
|
+
pinnedSessionIds.delete(id);
|
|
6898
|
+
unpinned.add(id);
|
|
6940
6899
|
} else if (key.startsWith('__sticky__')) {
|
|
6941
|
-
|
|
6942
|
-
|
|
6900
|
+
const id = key.slice('__sticky__'.length);
|
|
6901
|
+
stickySessionIds.delete(id);
|
|
6902
|
+
unpinned.add(id);
|
|
6943
6903
|
} else {
|
|
6944
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));
|
|
6945
6907
|
}
|
|
6946
6908
|
}
|
|
6947
|
-
if (
|
|
6909
|
+
if (unpinned.size) savePinnedSessions();
|
|
6910
|
+
for (const id of unpinned) offloadSessionPin(id);
|
|
6948
6911
|
const removed = orphaned.length;
|
|
6949
6912
|
|
|
6950
6913
|
showToast(removed ? `Cleaned ${removed} orphaned item${removed > 1 ? 's' : ''}` : 'No orphaned items found');
|
|
@@ -6961,6 +6924,10 @@ function matchKey(e, ...keys) {
|
|
|
6961
6924
|
return keys.some((k) => e.key === k || e.code === k);
|
|
6962
6925
|
}
|
|
6963
6926
|
|
|
6927
|
+
function shiftOnly(e, code) {
|
|
6928
|
+
return e.code === code && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
|
|
6929
|
+
}
|
|
6930
|
+
|
|
6964
6931
|
function dismissSessionFromList(sid) {
|
|
6965
6932
|
const prevIdx = selectedSessionIdx;
|
|
6966
6933
|
if (!setSessionDismissed(sid, true)) return;
|
|
@@ -7009,10 +6976,10 @@ async function confirmDismissBlockers(sid) {
|
|
|
7009
6976
|
const has = [sticky && 'a sticky pin', terminal && 'a running terminal'].filter(Boolean).join(' and ');
|
|
7010
6977
|
const [title, verb, okLabel] =
|
|
7011
6978
|
sticky && terminal
|
|
7012
|
-
? ['Unpin and
|
|
6979
|
+
? ['Unpin and End Terminal', 'Unpin, end the terminal', 'Unpin, end and dismiss']
|
|
7013
6980
|
: sticky
|
|
7014
6981
|
? ['Remove Sticky Pin', 'Remove the pin', 'Unpin and dismiss']
|
|
7015
|
-
: ['
|
|
6982
|
+
: ['End Terminal', 'End the terminal', 'End and dismiss'];
|
|
7016
6983
|
const ok = await confirmModal({ title, message: `"${name}" has ${has}. ${verb} and dismiss the session?`, okLabel });
|
|
7017
6984
|
if (!ok) return false;
|
|
7018
6985
|
if (sticky) unpinSession(sid);
|
|
@@ -7110,11 +7077,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
7110
7077
|
} else if (e.key === '?' && document.getElementById('help-modal').classList.contains('visible')) {
|
|
7111
7078
|
e.preventDefault();
|
|
7112
7079
|
closeHelpModal();
|
|
7113
|
-
} else if (
|
|
7114
|
-
e.code === 'KeyM' &&
|
|
7115
|
-
e.shiftKey &&
|
|
7116
|
-
document.getElementById('msg-detail-modal').classList.contains('visible')
|
|
7117
|
-
) {
|
|
7080
|
+
} else if (shiftOnly(e, 'KeyM') && document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
7118
7081
|
e.preventDefault();
|
|
7119
7082
|
closeMsgDetailModal();
|
|
7120
7083
|
} else if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
@@ -7177,6 +7140,10 @@ document.addEventListener('keydown', (e) => {
|
|
|
7177
7140
|
e.preventDefault();
|
|
7178
7141
|
return;
|
|
7179
7142
|
}
|
|
7143
|
+
if (isMenuKey(e) && ((focusZone === 'sidebar' && sgOpenMenuForKbSelection()) || openFocusedPaneMenu())) {
|
|
7144
|
+
e.preventDefault();
|
|
7145
|
+
return;
|
|
7146
|
+
}
|
|
7180
7147
|
const plainShift = !e.ctrlKey && !e.altKey && !e.metaKey;
|
|
7181
7148
|
if (plainShift && e.key === '|' && togglePaneSplit()) {
|
|
7182
7149
|
e.preventDefault();
|
|
@@ -7186,17 +7153,17 @@ document.addEventListener('keydown', (e) => {
|
|
|
7186
7153
|
e.preventDefault();
|
|
7187
7154
|
return;
|
|
7188
7155
|
}
|
|
7189
|
-
if (e
|
|
7156
|
+
if (shiftOnly(e, 'KeyL')) {
|
|
7190
7157
|
e.preventDefault();
|
|
7191
7158
|
toggleMessagePanel();
|
|
7192
7159
|
return;
|
|
7193
7160
|
}
|
|
7194
|
-
if (e
|
|
7161
|
+
if (shiftOnly(e, 'KeyP')) {
|
|
7195
7162
|
e.preventDefault();
|
|
7196
7163
|
openProjectPicker();
|
|
7197
7164
|
return;
|
|
7198
7165
|
}
|
|
7199
|
-
if (e
|
|
7166
|
+
if (shiftOnly(e, 'KeyM')) {
|
|
7200
7167
|
e.preventDefault();
|
|
7201
7168
|
const msgDetailModal = document.getElementById('msg-detail-modal');
|
|
7202
7169
|
if (msgDetailModal.classList.contains('visible')) {
|
|
@@ -7210,12 +7177,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
7210
7177
|
}
|
|
7211
7178
|
return;
|
|
7212
7179
|
}
|
|
7213
|
-
if (e
|
|
7180
|
+
if (shiftOnly(e, 'KeyS')) {
|
|
7214
7181
|
e.preventDefault();
|
|
7215
7182
|
showStorageManager();
|
|
7216
7183
|
return;
|
|
7217
7184
|
}
|
|
7218
|
-
if (e
|
|
7185
|
+
if (shiftOnly(e, 'KeyZ')) {
|
|
7219
7186
|
e.preventDefault();
|
|
7220
7187
|
toggleZenMode();
|
|
7221
7188
|
return;
|
|
@@ -7271,11 +7238,6 @@ document.addEventListener('keydown', (e) => {
|
|
|
7271
7238
|
activateSelectedSession();
|
|
7272
7239
|
return;
|
|
7273
7240
|
}
|
|
7274
|
-
const menuKey = e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey);
|
|
7275
|
-
if (menuKey && sgOpenMenuForKbSelection()) {
|
|
7276
|
-
e.preventDefault();
|
|
7277
|
-
return;
|
|
7278
|
-
}
|
|
7279
7241
|
}
|
|
7280
7242
|
|
|
7281
7243
|
// Board navigation
|
|
@@ -7340,17 +7302,17 @@ document.addEventListener('keydown', (e) => {
|
|
|
7340
7302
|
const cursorEl = focusZone === 'sidebar' ? sessionsList.querySelector('.kb-selected') : null;
|
|
7341
7303
|
const contextSid =
|
|
7342
7304
|
focusZone === 'sidebar' ? cursorEl?.dataset.sessionId || currentSessionId : selectedSessionId || currentSessionId;
|
|
7343
|
-
if (matchKey(e, 'KeyP')
|
|
7305
|
+
if (matchKey(e, 'KeyP')) {
|
|
7344
7306
|
e.preventDefault();
|
|
7345
7307
|
if (contextSid) openPlanForSession(contextSid);
|
|
7346
7308
|
return;
|
|
7347
7309
|
}
|
|
7348
|
-
if (matchKey(e, 'KeyI')
|
|
7310
|
+
if (matchKey(e, 'KeyI')) {
|
|
7349
7311
|
e.preventDefault();
|
|
7350
7312
|
if (contextSid) showSessionInfoModal(contextSid);
|
|
7351
7313
|
return;
|
|
7352
7314
|
}
|
|
7353
|
-
if (matchKey(e, 'KeyN')
|
|
7315
|
+
if (matchKey(e, 'KeyN')) {
|
|
7354
7316
|
e.preventDefault();
|
|
7355
7317
|
const cursorKey = _scratchpadKeyOf(cursorEl);
|
|
7356
7318
|
if (cursorKey) showScratchpad(cursorKey);
|
|
@@ -7368,13 +7330,13 @@ document.addEventListener('keydown', (e) => {
|
|
|
7368
7330
|
openMarketplace(mSession?.project);
|
|
7369
7331
|
return;
|
|
7370
7332
|
}
|
|
7371
|
-
if (
|
|
7333
|
+
if (ClaudeHub.comboOf(e) === 'ctrl+m') {
|
|
7372
7334
|
e.preventDefault();
|
|
7373
7335
|
const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
|
|
7374
7336
|
openMemory(mSession?.project);
|
|
7375
7337
|
return;
|
|
7376
7338
|
}
|
|
7377
|
-
if (
|
|
7339
|
+
if (ClaudeHub.comboOf(e) === 'ctrl+d') {
|
|
7378
7340
|
e.preventDefault();
|
|
7379
7341
|
if (!contextSid) return;
|
|
7380
7342
|
dismissSession(contextSid);
|
|
@@ -7394,10 +7356,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
7394
7356
|
showToast('No live session name: the session is not running');
|
|
7395
7357
|
return;
|
|
7396
7358
|
}
|
|
7397
|
-
|
|
7398
|
-
.writeText(text)
|
|
7399
|
-
.then(() => showToast(`Copied ${label}: ${e.ctrlKey ? text : text.slice(0, 8)}`, 'success'))
|
|
7400
|
-
.catch(() => showToast(`Failed to copy ${label}`));
|
|
7359
|
+
copyText(text, label, `Copied ${label}: ${e.ctrlKey ? text : text.slice(0, 8)}`);
|
|
7401
7360
|
return;
|
|
7402
7361
|
}
|
|
7403
7362
|
if (matchKey(e, 'KeyR')) {
|
|
@@ -8034,7 +7993,7 @@ async function sendReview() {
|
|
|
8034
7993
|
const items = reviewItems();
|
|
8035
7994
|
if (!items.length) return;
|
|
8036
7995
|
try {
|
|
8037
|
-
const res = await terminalFetch(`/api/sessions/${
|
|
7996
|
+
const res = await terminalFetch(apiPath`/api/sessions/${sessionId}/review`, 'POST', {
|
|
8038
7997
|
source,
|
|
8039
7998
|
comments: items.map(({ quote, comment, context }) => ({ quote, comment, ...context })),
|
|
8040
7999
|
});
|
|
@@ -8044,14 +8003,19 @@ async function sendReview() {
|
|
|
8044
8003
|
else if (data.delivered === 'terminal') showToast('Pasted into the terminal: press Enter there to send', 'info');
|
|
8045
8004
|
else {
|
|
8046
8005
|
const { markdown } = data;
|
|
8047
|
-
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
|
+
};
|
|
8048
8018
|
await copy();
|
|
8049
|
-
showToast(
|
|
8050
|
-
'Review copied. Paste it into the session.',
|
|
8051
|
-
'info',
|
|
8052
|
-
{ label: 'Copy again', onClick: copy },
|
|
8053
|
-
'Run /claude-code-kanban:follow there to send reviews directly.',
|
|
8054
|
-
);
|
|
8055
8019
|
}
|
|
8056
8020
|
clearReviewDrafts(key);
|
|
8057
8021
|
if (activeReview?.key === key) onSent?.();
|
|
@@ -8066,12 +8030,8 @@ const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
|
|
|
8066
8030
|
let currentPreviewPath = null;
|
|
8067
8031
|
|
|
8068
8032
|
function readStoredList(key) {
|
|
8069
|
-
|
|
8070
|
-
|
|
8071
|
-
return Array.isArray(arr) ? arr : [];
|
|
8072
|
-
} catch {
|
|
8073
|
-
return [];
|
|
8074
|
-
}
|
|
8033
|
+
const arr = store.readJson(key);
|
|
8034
|
+
return Array.isArray(arr) ? arr : [];
|
|
8075
8035
|
}
|
|
8076
8036
|
|
|
8077
8037
|
function getSessionPreviewPaths(sessionId) {
|
|
@@ -8105,7 +8065,7 @@ function removeSessionPreviewPath(sessionId, filePath) {
|
|
|
8105
8065
|
}
|
|
8106
8066
|
|
|
8107
8067
|
function saveSessionPreviewPaths(sessionId, paths) {
|
|
8108
|
-
if (paths.length) store.
|
|
8068
|
+
if (paths.length) store.writeJson(PREVIEW_STORAGE_PREFIX + sessionId, paths.slice(0, MAX_LINKED_DOCS));
|
|
8109
8069
|
else store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
8110
8070
|
}
|
|
8111
8071
|
|
|
@@ -8122,14 +8082,7 @@ function setSessionDocLink(sessionId, filePath, unlink) {
|
|
|
8122
8082
|
|
|
8123
8083
|
// Links sent while no tab was open reach the browser only through the server copy.
|
|
8124
8084
|
async function mergeServerLinkedDocs() {
|
|
8125
|
-
|
|
8126
|
-
try {
|
|
8127
|
-
const res = await fetch('/api/document/links');
|
|
8128
|
-
if (!res.ok) return;
|
|
8129
|
-
bySession = await res.json();
|
|
8130
|
-
} catch {
|
|
8131
|
-
return;
|
|
8132
|
-
}
|
|
8085
|
+
const bySession = await getJson('/api/document/links', {});
|
|
8133
8086
|
for (const [sessionId, paths] of Object.entries(bySession || {})) {
|
|
8134
8087
|
const local = getSessionPreviewPaths(sessionId);
|
|
8135
8088
|
const known = new Set(local.map(canonicalPath));
|
|
@@ -8141,8 +8094,9 @@ async function mergeServerLinkedDocs() {
|
|
|
8141
8094
|
}
|
|
8142
8095
|
|
|
8143
8096
|
function forgetServerLinkedDoc(sessionId, filePath) {
|
|
8144
|
-
|
|
8145
|
-
|
|
8097
|
+
api(apiPath`/api/document/links/${sessionId}`, { method: 'DELETE', query: { path: filePath || null } }).catch(
|
|
8098
|
+
() => {},
|
|
8099
|
+
);
|
|
8146
8100
|
}
|
|
8147
8101
|
|
|
8148
8102
|
function afterLinkedDocsChanged(sessionId) {
|
|
@@ -8223,13 +8177,24 @@ function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
|
|
|
8223
8177
|
// backtracking on the main thread, and .jsonl/.log files in a scratchpad reach megabytes.
|
|
8224
8178
|
const HLJS_MAX_CHARS = 256 * 1024;
|
|
8225
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
|
+
|
|
8226
8193
|
function renderSourcePreview(filePath, content) {
|
|
8227
|
-
const ext = (filePath
|
|
8228
|
-
|
|
8229
|
-
|
|
8230
|
-
|
|
8231
|
-
return `<pre class="preview-source"><code class="hljs language-${escapeHtml(ext)}">${value}</code></pre>`;
|
|
8232
|
-
} 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>`;
|
|
8233
8198
|
}
|
|
8234
8199
|
return `<pre class="preview-source"><code class="hljs">${escapeHtml(content)}</code></pre>`;
|
|
8235
8200
|
}
|
|
@@ -8304,7 +8269,7 @@ function updatePreviewLinkBtn() {
|
|
|
8304
8269
|
btn.style.display = '';
|
|
8305
8270
|
const linked = isPreviewLinkedToCurrentSession();
|
|
8306
8271
|
btn.title = linked ? 'Unlink from current session' : 'Link to current session';
|
|
8307
|
-
btn.style.color = linked ? 'var(--accent
|
|
8272
|
+
btn.style.color = linked ? 'var(--accent)' : '';
|
|
8308
8273
|
}
|
|
8309
8274
|
|
|
8310
8275
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
@@ -8359,16 +8324,14 @@ function openPreviewInEditor() {
|
|
|
8359
8324
|
async function openPreviewByPath(filePath, base, onUnsupported) {
|
|
8360
8325
|
if (!filePath) return;
|
|
8361
8326
|
try {
|
|
8362
|
-
const
|
|
8363
|
-
if (base) qs.set('base', base);
|
|
8364
|
-
const r = await fetch(`/api/preview?${qs}`);
|
|
8327
|
+
const r = await api('/api/preview', { query: { path: filePath, base: base || null } });
|
|
8365
8328
|
if (!r.ok) {
|
|
8366
|
-
showToast('Preview file unavailable');
|
|
8329
|
+
showToast('Preview file unavailable', 'error');
|
|
8367
8330
|
return;
|
|
8368
8331
|
}
|
|
8369
8332
|
const data = await r.json();
|
|
8370
8333
|
if (data.exists === false) {
|
|
8371
|
-
showToast('File not found');
|
|
8334
|
+
showToast('File not found', 'error');
|
|
8372
8335
|
return;
|
|
8373
8336
|
}
|
|
8374
8337
|
if (data.kind === null) {
|
|
@@ -8378,7 +8341,7 @@ async function openPreviewByPath(filePath, base, onUnsupported) {
|
|
|
8378
8341
|
}
|
|
8379
8342
|
openPreviewModal(data.path, data.content, data.kind);
|
|
8380
8343
|
} catch {
|
|
8381
|
-
showToast('Failed to load preview');
|
|
8344
|
+
showToast('Failed to load preview', 'error');
|
|
8382
8345
|
}
|
|
8383
8346
|
}
|
|
8384
8347
|
|
|
@@ -8450,12 +8413,8 @@ async function loadPreviewKinds(sessionId, paths) {
|
|
|
8450
8413
|
try {
|
|
8451
8414
|
await Promise.all(
|
|
8452
8415
|
pending.map(async (p) => {
|
|
8453
|
-
|
|
8454
|
-
|
|
8455
|
-
const r = await fetch(`/api/file/resolve?path=${encodeURIComponent(p)}`);
|
|
8456
|
-
if (r.ok) kind = (await r.json()).kind;
|
|
8457
|
-
} catch (_) {}
|
|
8458
|
-
previewKindByPath.set(p, kind);
|
|
8416
|
+
const data = await getJson('/api/file/resolve', {}, { query: { path: p } });
|
|
8417
|
+
previewKindByPath.set(p, data.kind ?? null);
|
|
8459
8418
|
}),
|
|
8460
8419
|
);
|
|
8461
8420
|
} finally {
|
|
@@ -8475,21 +8434,18 @@ function getOfferedPadPaths(sessionId) {
|
|
|
8475
8434
|
}
|
|
8476
8435
|
|
|
8477
8436
|
function setOfferedPadPaths(sessionId, paths) {
|
|
8478
|
-
store.
|
|
8437
|
+
store.writeJson(PAD_LINKED_PREFIX + sessionId, paths);
|
|
8479
8438
|
}
|
|
8480
8439
|
|
|
8481
8440
|
async function loadSessionPads(sessionId) {
|
|
8482
8441
|
if (!sessionId || padsFetched.has(sessionId)) return;
|
|
8483
8442
|
padsFetched.add(sessionId);
|
|
8484
|
-
|
|
8485
|
-
|
|
8486
|
-
const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/pads`);
|
|
8487
|
-
if (!r.ok) throw new Error(r.status);
|
|
8488
|
-
pads = (await r.json()).pads || [];
|
|
8489
|
-
} catch (_) {
|
|
8443
|
+
const data = await getJson(apiPath`/api/sessions/${sessionId}/pads`);
|
|
8444
|
+
if (!data) {
|
|
8490
8445
|
padsFetched.delete(sessionId);
|
|
8491
8446
|
return;
|
|
8492
8447
|
}
|
|
8448
|
+
const pads = data.pads || [];
|
|
8493
8449
|
const offered = new Set(getOfferedPadPaths(sessionId).map(canonicalPath));
|
|
8494
8450
|
const fresh = pads.map((p) => p.path).filter((p) => !offered.has(canonicalPath(p)));
|
|
8495
8451
|
if (!fresh.length) return;
|
|
@@ -8652,15 +8608,10 @@ function makeSectionLoader({ attr, endpoint, pick, innerHtml, ttlMs = 0 }) {
|
|
|
8652
8608
|
const cached = store.get(sessionId);
|
|
8653
8609
|
if (cached && Date.now() - cached.at < ttlMs) return;
|
|
8654
8610
|
inFlight.add(sessionId);
|
|
8655
|
-
|
|
8656
|
-
|
|
8657
|
-
|
|
8658
|
-
|
|
8659
|
-
} catch (_) {
|
|
8660
|
-
return;
|
|
8661
|
-
} finally {
|
|
8662
|
-
inFlight.delete(sessionId);
|
|
8663
|
-
}
|
|
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) });
|
|
8664
8615
|
paintSectionSlots(attr, sessionId, innerHtml(sessionId));
|
|
8665
8616
|
},
|
|
8666
8617
|
};
|
|
@@ -8910,13 +8861,9 @@ async function toggleScratchFolder(sessionId, dirPath) {
|
|
|
8910
8861
|
}
|
|
8911
8862
|
scratchFolders.set(dirPath, { status: 'loading', children: [] });
|
|
8912
8863
|
scratchFilesSection.repaint(sessionId);
|
|
8913
|
-
|
|
8914
|
-
|
|
8915
|
-
|
|
8916
|
-
if (res.ok) entry = { status: 'ready', children: (await res.json()).files || [] };
|
|
8917
|
-
} catch (_) {
|
|
8918
|
-
// Reads "Failed to load"; closing and reopening the folder retries.
|
|
8919
|
-
}
|
|
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: [] };
|
|
8920
8867
|
// The user may have closed the folder while the request was in flight.
|
|
8921
8868
|
if (scratchFolders.has(dirPath)) {
|
|
8922
8869
|
scratchFolders.set(dirPath, entry);
|
|
@@ -9017,10 +8964,7 @@ async function linkFileByPath(sessionId, raw, slot) {
|
|
|
9017
8964
|
}
|
|
9018
8965
|
} else {
|
|
9019
8966
|
try {
|
|
9020
|
-
const
|
|
9021
|
-
const base = getSessionBaseDir(sessionId);
|
|
9022
|
-
if (base) qs.set('base', base);
|
|
9023
|
-
const r = await fetch(`/api/file/resolve?${qs}`);
|
|
8967
|
+
const r = await api('/api/file/resolve', { query: { path: value, base: getSessionBaseDir(sessionId) || null } });
|
|
9024
8968
|
const data = await r.json().catch(() => ({}));
|
|
9025
8969
|
if (!r.ok || !data.exists) {
|
|
9026
8970
|
fail(data.error || 'File not found');
|
|
@@ -9247,7 +9191,12 @@ function setupEventSource() {
|
|
|
9247
9191
|
connect();
|
|
9248
9192
|
}
|
|
9249
9193
|
|
|
9250
|
-
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
|
+
};
|
|
9251
9200
|
const COST_THRESHOLDS = { green: 0.5, yellow: 2, orange: 5 };
|
|
9252
9201
|
const MODEL_THRESHOLDS = [
|
|
9253
9202
|
{ match: /sonnet/i, yellow: 120000, orange: 200000, red: 300000 },
|
|
@@ -9707,6 +9656,7 @@ function showWaitingDetail() {
|
|
|
9707
9656
|
document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
|
|
9708
9657
|
currentPinDetailId = null;
|
|
9709
9658
|
updateMsgDetailPinState();
|
|
9659
|
+
syncMsgDetailPaneBtn();
|
|
9710
9660
|
overlay.classList.add('visible');
|
|
9711
9661
|
}
|
|
9712
9662
|
|
|
@@ -9964,10 +9914,7 @@ function renderMarkdown(text) {
|
|
|
9964
9914
|
}
|
|
9965
9915
|
|
|
9966
9916
|
function isLightTheme() {
|
|
9967
|
-
|
|
9968
|
-
return (
|
|
9969
|
-
document.body.classList.contains('light') || (!saved && window.matchMedia('(prefers-color-scheme: light)').matches)
|
|
9970
|
-
);
|
|
9917
|
+
return document.body.classList.contains('light');
|
|
9971
9918
|
}
|
|
9972
9919
|
|
|
9973
9920
|
function getMermaidTheme() {
|
|
@@ -10073,28 +10020,13 @@ async function copyAgentTabActive(groupId, btn) {
|
|
|
10073
10020
|
copyAgentTab(key, btn);
|
|
10074
10021
|
}
|
|
10075
10022
|
|
|
10076
|
-
const
|
|
10077
|
-
|
|
10078
|
-
|
|
10079
|
-
|
|
10080
|
-
|
|
10081
|
-
|
|
10082
|
-
|
|
10083
|
-
{ bg: 'rgba(22, 163, 74, 0.14)', color: '#15803d' }, // green
|
|
10084
|
-
{ bg: 'rgba(99, 102, 241, 0.14)', color: '#4f46e5' }, // indigo
|
|
10085
|
-
];
|
|
10086
|
-
const namedColorMap = {
|
|
10087
|
-
red: { bg: 'rgba(239, 68, 68, 0.14)', color: '#dc2626' },
|
|
10088
|
-
blue: { bg: 'rgba(37, 99, 235, 0.14)', color: '#1d5bbf' },
|
|
10089
|
-
green: { bg: 'rgba(22, 163, 74, 0.14)', color: '#15803d' },
|
|
10090
|
-
purple: { bg: 'rgba(168, 85, 247, 0.14)', color: '#7c3aed' },
|
|
10091
|
-
orange: { bg: 'rgba(234, 88, 12, 0.14)', color: '#c2410c' },
|
|
10092
|
-
pink: { bg: 'rgba(219, 39, 119, 0.14)', color: '#b5246a' },
|
|
10093
|
-
yellow: { bg: 'rgba(202, 138, 4, 0.14)', color: '#92700c' },
|
|
10094
|
-
teal: { bg: 'rgba(14, 165, 133, 0.14)', color: '#0d7d65' },
|
|
10095
|
-
indigo: { bg: 'rgba(99, 102, 241, 0.14)', color: '#4f46e5' },
|
|
10096
|
-
cyan: { bg: 'rgba(6, 182, 212, 0.14)', color: '#0891b2' },
|
|
10097
|
-
};
|
|
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)]));
|
|
10098
10030
|
const ownerColorCache = {};
|
|
10099
10031
|
const teamColorMap = {};
|
|
10100
10032
|
function isInternalTask(task) {
|
|
@@ -10406,7 +10338,7 @@ document.addEventListener('click', (e) => {
|
|
|
10406
10338
|
if (breadcrumb) {
|
|
10407
10339
|
e.stopPropagation();
|
|
10408
10340
|
const path = breadcrumb.dataset.fullPath;
|
|
10409
|
-
if (path)
|
|
10341
|
+
if (path) copyText(path, 'project path');
|
|
10410
10342
|
return;
|
|
10411
10343
|
}
|
|
10412
10344
|
|
|
@@ -10427,13 +10359,13 @@ document.addEventListener('click', (e) => {
|
|
|
10427
10359
|
|
|
10428
10360
|
if (e.target.closest('.pinned-ungroup-btn')) {
|
|
10429
10361
|
e.stopPropagation();
|
|
10430
|
-
store.setItem(
|
|
10362
|
+
store.setItem(GROUP_PINNED_KEY, 'false');
|
|
10431
10363
|
renderSessions();
|
|
10432
10364
|
return;
|
|
10433
10365
|
}
|
|
10434
10366
|
|
|
10435
10367
|
if (e.target.closest('.pinned-regroup-banner')) {
|
|
10436
|
-
store.setItem(
|
|
10368
|
+
store.setItem(GROUP_PINNED_KEY, 'true');
|
|
10437
10369
|
renderSessions();
|
|
10438
10370
|
return;
|
|
10439
10371
|
}
|
|
@@ -10449,10 +10381,15 @@ document.addEventListener('click', (e) => {
|
|
|
10449
10381
|
}
|
|
10450
10382
|
if (sgHeader.dataset.transientGroup && e.target.closest('.sg-delete')) {
|
|
10451
10383
|
const name = sgHeader.dataset.transientGroup;
|
|
10452
|
-
|
|
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;
|
|
10453
10390
|
sgDeleteTransient(name);
|
|
10454
10391
|
renderSessions();
|
|
10455
|
-
}
|
|
10392
|
+
});
|
|
10456
10393
|
return;
|
|
10457
10394
|
}
|
|
10458
10395
|
const groupId = sgHeader.dataset.groupId;
|
|
@@ -10463,10 +10400,15 @@ document.addEventListener('click', (e) => {
|
|
|
10463
10400
|
return;
|
|
10464
10401
|
}
|
|
10465
10402
|
if (e.target.closest('.sg-delete')) {
|
|
10466
|
-
|
|
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;
|
|
10467
10409
|
sgDeleteGroup(groupId);
|
|
10468
10410
|
renderSessions();
|
|
10469
|
-
}
|
|
10411
|
+
});
|
|
10470
10412
|
return;
|
|
10471
10413
|
}
|
|
10472
10414
|
} else if (!sgHeader.dataset.transientGroup) return;
|
|
@@ -10511,15 +10453,9 @@ let projectsCache = null;
|
|
|
10511
10453
|
|
|
10512
10454
|
async function loadProjects() {
|
|
10513
10455
|
if (!projectsCacheDirty && projectsCache) return projectsCache;
|
|
10514
|
-
|
|
10515
|
-
|
|
10516
|
-
|
|
10517
|
-
projects = await res.json();
|
|
10518
|
-
} catch (_e) {
|
|
10519
|
-
projects = [...new Set(sessions.map((s) => s.project).filter(Boolean))]
|
|
10520
|
-
.sort()
|
|
10521
|
-
.map((p) => ({ path: p, modifiedAt: null }));
|
|
10522
|
-
}
|
|
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 }));
|
|
10523
10459
|
projectsCache = projects;
|
|
10524
10460
|
projectsCacheDirty = false;
|
|
10525
10461
|
return projects;
|
|
@@ -10558,52 +10494,36 @@ function updateThemeColor(isLight) {
|
|
|
10558
10494
|
//#endregion
|
|
10559
10495
|
|
|
10560
10496
|
//#region THEME
|
|
10561
|
-
function
|
|
10562
|
-
|
|
10563
|
-
|
|
10564
|
-
|
|
10565
|
-
|
|
10566
|
-
|
|
10567
|
-
} else {
|
|
10568
|
-
document.body.classList.add('light');
|
|
10569
|
-
document.body.classList.remove('dark-forced');
|
|
10570
|
-
store.setItem('theme', 'light');
|
|
10571
|
-
}
|
|
10572
|
-
updateThemeIcon();
|
|
10573
|
-
updateThemeColor(!isCurrentlyLight);
|
|
10574
|
-
syncHljsTheme();
|
|
10575
|
-
reinitMermaidTheme();
|
|
10576
|
-
}
|
|
10577
|
-
|
|
10578
|
-
function syncHljsTheme() {
|
|
10579
|
-
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);
|
|
10580
10503
|
const dark$ = document.getElementById('hljs-theme-dark');
|
|
10581
10504
|
const light$ = document.getElementById('hljs-theme-light');
|
|
10582
10505
|
if (dark$) dark$.disabled = light;
|
|
10583
10506
|
if (light$) light$.disabled = !light;
|
|
10507
|
+
reinitMermaidTheme();
|
|
10584
10508
|
}
|
|
10585
10509
|
|
|
10586
|
-
function
|
|
10587
|
-
const light = isLightTheme();
|
|
10588
|
-
|
|
10589
|
-
|
|
10510
|
+
function toggleTheme() {
|
|
10511
|
+
const light = !isLightTheme();
|
|
10512
|
+
store.setItem(THEME_KEY, light ? 'light' : 'dark');
|
|
10513
|
+
applyTheme(light);
|
|
10590
10514
|
}
|
|
10591
10515
|
|
|
10592
10516
|
function loadTheme() {
|
|
10593
|
-
const saved = store.getItem(
|
|
10594
|
-
|
|
10595
|
-
|
|
10596
|
-
|
|
10597
|
-
|
|
10598
|
-
|
|
10599
|
-
|
|
10600
|
-
}
|
|
10601
|
-
// If no saved preference, system prefers-color-scheme CSS handles it
|
|
10602
|
-
updateThemeIcon();
|
|
10603
|
-
updateThemeColor(document.body.classList.contains('light'));
|
|
10604
|
-
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
|
+
});
|
|
10605
10525
|
buildThemeMenu();
|
|
10606
|
-
const colorTheme = store.getItem(
|
|
10526
|
+
const colorTheme = store.getItem(COLOR_THEME_KEY);
|
|
10607
10527
|
if (colorTheme) document.body.dataset.colorTheme = colorTheme;
|
|
10608
10528
|
syncColorThemeSelect(colorTheme || 'ember');
|
|
10609
10529
|
}
|
|
@@ -10661,10 +10581,10 @@ function syncColorThemeSelect(id) {
|
|
|
10661
10581
|
function setColorTheme(id) {
|
|
10662
10582
|
if (!id || id === 'ember') {
|
|
10663
10583
|
delete document.body.dataset.colorTheme;
|
|
10664
|
-
store.removeItem(
|
|
10584
|
+
store.removeItem(COLOR_THEME_KEY);
|
|
10665
10585
|
} else {
|
|
10666
10586
|
document.body.dataset.colorTheme = id;
|
|
10667
|
-
store.setItem(
|
|
10587
|
+
store.setItem(COLOR_THEME_KEY, id);
|
|
10668
10588
|
}
|
|
10669
10589
|
syncColorThemeSelect(id);
|
|
10670
10590
|
}
|
|
@@ -10676,13 +10596,13 @@ function expandSidebar() {
|
|
|
10676
10596
|
const sidebar = document.querySelector('.sidebar');
|
|
10677
10597
|
if (!sidebar.classList.contains('collapsed')) return;
|
|
10678
10598
|
sidebar.classList.remove('collapsed');
|
|
10679
|
-
store.setItem(
|
|
10599
|
+
store.setItem(SIDEBAR_COLLAPSED_KEY, false);
|
|
10680
10600
|
}
|
|
10681
10601
|
|
|
10682
10602
|
function toggleSidebar() {
|
|
10683
10603
|
const sidebar = document.querySelector('.sidebar');
|
|
10684
10604
|
const collapsed = sidebar.classList.toggle('collapsed');
|
|
10685
|
-
store.setItem(
|
|
10605
|
+
store.setItem(SIDEBAR_COLLAPSED_KEY, collapsed);
|
|
10686
10606
|
if (collapsed) {
|
|
10687
10607
|
sidebar.style.width = '';
|
|
10688
10608
|
if (focusZone === 'sidebar') setFocusZone('board');
|
|
@@ -10694,10 +10614,10 @@ function toggleSidebar() {
|
|
|
10694
10614
|
|
|
10695
10615
|
function loadSidebarState() {
|
|
10696
10616
|
const sidebar = document.querySelector('.sidebar');
|
|
10697
|
-
if (store.getItem(
|
|
10617
|
+
if (store.getItem(SIDEBAR_COLLAPSED_KEY) === 'true') {
|
|
10698
10618
|
sidebar.classList.add('collapsed');
|
|
10699
10619
|
}
|
|
10700
|
-
const w = store.getItem(
|
|
10620
|
+
const w = store.getItem(SIDEBAR_WIDTH_KEY);
|
|
10701
10621
|
if (w) {
|
|
10702
10622
|
sidebar.style.setProperty('--sidebar-width', w);
|
|
10703
10623
|
}
|
|
@@ -10752,12 +10672,12 @@ function initSidebarResize() {
|
|
|
10752
10672
|
},
|
|
10753
10673
|
onEnd() {
|
|
10754
10674
|
sidebar.classList.remove('resizing');
|
|
10755
|
-
store.setItem(
|
|
10675
|
+
store.setItem(SIDEBAR_WIDTH_KEY, sidebar.style.getPropertyValue('--sidebar-width'));
|
|
10756
10676
|
},
|
|
10757
10677
|
});
|
|
10758
10678
|
}
|
|
10759
10679
|
|
|
10760
|
-
function initPanelResize(panelId, handleId, cssVar
|
|
10680
|
+
function initPanelResize(panelId, handleId, cssVar) {
|
|
10761
10681
|
const panel = document.getElementById(panelId);
|
|
10762
10682
|
const handle = document.getElementById(handleId);
|
|
10763
10683
|
let startWidth;
|
|
@@ -10773,7 +10693,7 @@ function initPanelResize(panelId, handleId, cssVar, storageKey) {
|
|
|
10773
10693
|
},
|
|
10774
10694
|
onEnd() {
|
|
10775
10695
|
panel.classList.remove('resizing');
|
|
10776
|
-
store.setItem(
|
|
10696
|
+
store.setItem(panelWidthKey(panelId), panel.style.getPropertyValue(cssVar));
|
|
10777
10697
|
},
|
|
10778
10698
|
});
|
|
10779
10699
|
}
|
|
@@ -10783,7 +10703,7 @@ function loadPanelWidths() {
|
|
|
10783
10703
|
['detail-panel', '--detail-panel-width'],
|
|
10784
10704
|
['message-panel', '--message-panel-width'],
|
|
10785
10705
|
].forEach(([id, cssVar]) => {
|
|
10786
|
-
const w = store.getItem(
|
|
10706
|
+
const w = store.getItem(panelWidthKey(id));
|
|
10787
10707
|
if (w) document.getElementById(id).style.setProperty(cssVar, w);
|
|
10788
10708
|
});
|
|
10789
10709
|
}
|
|
@@ -10815,29 +10735,18 @@ async function showSessionInfoModal(sessionId) {
|
|
|
10815
10735
|
};
|
|
10816
10736
|
|
|
10817
10737
|
const teamPromise = session.isTeam
|
|
10818
|
-
?
|
|
10819
|
-
.then((r) => (r.ok ? r.json() : null))
|
|
10820
|
-
.catch(() => null)
|
|
10738
|
+
? getJson(apiPath`/api/teams/${session.teamName || sessionId}`)
|
|
10821
10739
|
: Promise.resolve(null);
|
|
10822
10740
|
|
|
10823
|
-
const planPromise =
|
|
10824
|
-
|
|
10825
|
-
|
|
10826
|
-
|
|
10827
|
-
if (data?.content) _pendingPlanPath = data.path || null;
|
|
10828
|
-
return data?.content || null;
|
|
10829
|
-
});
|
|
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
|
+
});
|
|
10830
10745
|
|
|
10831
10746
|
const tasksPromise =
|
|
10832
|
-
cachedTasks.length > 0
|
|
10833
|
-
? Promise.resolve(cachedTasks)
|
|
10834
|
-
: fetch(`/api/sessions/${sessionId}`)
|
|
10835
|
-
.then((r) => (r.ok ? r.json() : []))
|
|
10836
|
-
.catch(() => []);
|
|
10747
|
+
cachedTasks.length > 0 ? Promise.resolve(cachedTasks) : getJson(apiPath`/api/sessions/${sessionId}`, []);
|
|
10837
10748
|
|
|
10838
|
-
const parentPromise =
|
|
10839
|
-
.then((r) => (r.ok ? r.json() : null))
|
|
10840
|
-
.catch(() => null);
|
|
10749
|
+
const parentPromise = getJson(apiPath`/api/sessions/${sessionId}/parent`);
|
|
10841
10750
|
|
|
10842
10751
|
const [teamConfig, planContent, tasks, parentInfo] = await Promise.all([
|
|
10843
10752
|
teamPromise,
|
|
@@ -10983,13 +10892,13 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
10983
10892
|
_pendingPlanContent = planContent;
|
|
10984
10893
|
const titleMatch = planContent.match(/^#\s+(.+)$/m);
|
|
10985
10894
|
const planTitle = titleMatch ? titleMatch[1].trim() : null;
|
|
10986
|
-
html += `<div data-plan-card="1" onclick="openPlanModal()"
|
|
10987
|
-
<span
|
|
10988
|
-
<div
|
|
10989
|
-
<div
|
|
10990
|
-
${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>` : ''}
|
|
10991
10900
|
</div>
|
|
10992
|
-
<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>
|
|
10993
10902
|
</div>`;
|
|
10994
10903
|
}
|
|
10995
10904
|
|
|
@@ -11053,7 +10962,6 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
11053
10962
|
// Bind to the section, not bodyEl: bodyEl outlives the modal, so listeners on it
|
|
11054
10963
|
// would stack up once per open and fire the click handler N times.
|
|
11055
10964
|
bindLinkedDocsHandlers(bodyEl.querySelector('.linked-docs-section'), session.id);
|
|
11056
|
-
const alreadyVisible = modal.classList.contains('visible');
|
|
11057
10965
|
_infoModalSessionId = session.id;
|
|
11058
10966
|
updateStickyBtnState();
|
|
11059
10967
|
updateDismissBtnState();
|
|
@@ -11065,21 +10973,6 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
11065
10973
|
if (mkBtn) mkBtn.style.display = proj && hub.can('project.plugins') ? '' : 'none';
|
|
11066
10974
|
if (memBtn) memBtn.style.display = proj && hub.can('project.memory') ? '' : 'none';
|
|
11067
10975
|
modal.classList.add('visible');
|
|
11068
|
-
|
|
11069
|
-
if (alreadyVisible) return; // re-render during deferred hydration — key handler already attached
|
|
11070
|
-
|
|
11071
|
-
const keyHandler = (e) => {
|
|
11072
|
-
if (e.key === 'Escape') {
|
|
11073
|
-
// An inline editor inside the modal owns Escape first — it reverts itself.
|
|
11074
|
-
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
|
|
11075
|
-
if (document.getElementById('plan-modal').classList.contains('visible')) return;
|
|
11076
|
-
if (document.getElementById('tool-stats-modal').classList.contains('visible')) return;
|
|
11077
|
-
e.preventDefault();
|
|
11078
|
-
closeTeamModal();
|
|
11079
|
-
document.removeEventListener('keydown', keyHandler);
|
|
11080
|
-
}
|
|
11081
|
-
};
|
|
11082
|
-
document.addEventListener('keydown', keyHandler);
|
|
11083
10976
|
}
|
|
11084
10977
|
|
|
11085
10978
|
function closeTeamModal() {
|
|
@@ -11464,30 +11357,23 @@ function initProjectPicker() {
|
|
|
11464
11357
|
//#region PLAN
|
|
11465
11358
|
function refreshOpenPlan() {
|
|
11466
11359
|
if (!_planSessionId || !document.getElementById('plan-modal').classList.contains('visible')) return;
|
|
11467
|
-
|
|
11468
|
-
|
|
11469
|
-
|
|
11470
|
-
|
|
11471
|
-
|
|
11472
|
-
|
|
11473
|
-
|
|
11474
|
-
|
|
11475
|
-
|
|
11476
|
-
})
|
|
11477
|
-
.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
|
+
});
|
|
11478
11369
|
}
|
|
11479
11370
|
|
|
11480
11371
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
11481
11372
|
function showLoopModal(sessionId) {
|
|
11482
11373
|
const body = document.getElementById('loop-modal-body');
|
|
11483
|
-
body.innerHTML = '<div
|
|
11374
|
+
body.innerHTML = '<div class="modal-status">Loading…</div>';
|
|
11484
11375
|
document.getElementById('loop-modal').classList.add('visible');
|
|
11485
|
-
|
|
11486
|
-
.then((r) => (r.ok ? r.json() : { wakeups: [], crons: [] }))
|
|
11487
|
-
.catch(() => ({ wakeups: [], crons: [] }))
|
|
11488
|
-
.then((data) => {
|
|
11489
|
-
renderLoopModalBody(data);
|
|
11490
|
-
});
|
|
11376
|
+
getJson(apiPath`/api/sessions/${sessionId}/loop`, { wakeups: [], crons: [] }).then(renderLoopModalBody);
|
|
11491
11377
|
}
|
|
11492
11378
|
|
|
11493
11379
|
function fmtLoopDelay(s) {
|
|
@@ -11554,8 +11440,7 @@ function renderLoopModalBody(data) {
|
|
|
11554
11440
|
const wakeups = data.wakeups || [];
|
|
11555
11441
|
const crons = data.crons || [];
|
|
11556
11442
|
if (!wakeups.length && !crons.length) {
|
|
11557
|
-
body.innerHTML =
|
|
11558
|
-
'<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>';
|
|
11559
11444
|
return;
|
|
11560
11445
|
}
|
|
11561
11446
|
const section = (title, items, kind) =>
|
|
@@ -11604,23 +11489,19 @@ function showWorkflowModal(sessionId) {
|
|
|
11604
11489
|
_workflowSessionId = sessionId;
|
|
11605
11490
|
setWorkflowHeader('Workflows', null);
|
|
11606
11491
|
const body = document.getElementById('workflow-modal-body');
|
|
11607
|
-
body.innerHTML = '<div
|
|
11492
|
+
body.innerHTML = '<div class="modal-status">Loading…</div>';
|
|
11608
11493
|
document.getElementById('workflow-modal').classList.add('visible');
|
|
11609
|
-
|
|
11610
|
-
|
|
11611
|
-
.
|
|
11612
|
-
|
|
11613
|
-
|
|
11614
|
-
|
|
11615
|
-
|
|
11616
|
-
|
|
11617
|
-
|
|
11618
|
-
|
|
11619
|
-
|
|
11620
|
-
} else {
|
|
11621
|
-
renderWorkflowPicker(_workflowList);
|
|
11622
|
-
}
|
|
11623
|
-
});
|
|
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
|
+
});
|
|
11624
11505
|
}
|
|
11625
11506
|
|
|
11626
11507
|
function setWorkflowHeader(name, wfId) {
|
|
@@ -11658,17 +11539,14 @@ function showWorkflowRun(wfId) {
|
|
|
11658
11539
|
const name = _workflowList.find((w) => w.id === wfId)?.name || wfId;
|
|
11659
11540
|
setWorkflowHeader(name, wfId);
|
|
11660
11541
|
const body = document.getElementById('workflow-modal-body');
|
|
11661
|
-
body.innerHTML = '<div
|
|
11662
|
-
|
|
11663
|
-
|
|
11664
|
-
|
|
11665
|
-
|
|
11666
|
-
|
|
11667
|
-
|
|
11668
|
-
|
|
11669
|
-
}
|
|
11670
|
-
renderWorkflowRun(run);
|
|
11671
|
-
});
|
|
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
|
+
});
|
|
11672
11550
|
}
|
|
11673
11551
|
|
|
11674
11552
|
function fmtTokens(t) {
|
|
@@ -11752,27 +11630,24 @@ function renderWorkflowRun(run) {
|
|
|
11752
11630
|
|
|
11753
11631
|
function loadWorkflowCode(wfId, codeEl) {
|
|
11754
11632
|
if (!codeEl) return;
|
|
11755
|
-
codeEl.innerHTML = '<div
|
|
11756
|
-
|
|
11757
|
-
|
|
11758
|
-
|
|
11759
|
-
|
|
11760
|
-
|
|
11761
|
-
|
|
11762
|
-
|
|
11763
|
-
}
|
|
11764
|
-
|
|
11765
|
-
|
|
11766
|
-
|
|
11767
|
-
|
|
11768
|
-
|
|
11769
|
-
}
|
|
11770
|
-
codeEl.innerHTML = `<pre><code class="hljs language-javascript">${highlighted}</code></pre>`;
|
|
11771
|
-
});
|
|
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
|
+
});
|
|
11772
11647
|
}
|
|
11773
11648
|
|
|
11774
11649
|
function openWorkflowInEditor(wfId) {
|
|
11775
|
-
postAndToast(`/api/sessions/${_workflowSessionId}/workflows/${
|
|
11650
|
+
postAndToast(apiPath`/api/sessions/${_workflowSessionId}/workflows/${wfId}/open`, {}, 'in editor');
|
|
11776
11651
|
}
|
|
11777
11652
|
|
|
11778
11653
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
@@ -11788,17 +11663,14 @@ function closeWorkflowModal() {
|
|
|
11788
11663
|
}
|
|
11789
11664
|
|
|
11790
11665
|
function openPlanForSession(sid) {
|
|
11791
|
-
|
|
11792
|
-
|
|
11793
|
-
|
|
11794
|
-
|
|
11795
|
-
|
|
11796
|
-
|
|
11797
|
-
|
|
11798
|
-
|
|
11799
|
-
openPlanModal();
|
|
11800
|
-
}
|
|
11801
|
-
});
|
|
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
|
+
});
|
|
11802
11674
|
}
|
|
11803
11675
|
|
|
11804
11676
|
function openPlanModal() {
|
|
@@ -11814,6 +11686,7 @@ function openPlanModal() {
|
|
|
11814
11686
|
controls.innerHTML = planApprovalControlsHtml('plan-modal-reject-feedback');
|
|
11815
11687
|
document.querySelector('#plan-modal .modal-footer').prepend(controls);
|
|
11816
11688
|
}
|
|
11689
|
+
syncPlanPaneBtn();
|
|
11817
11690
|
document.getElementById('plan-modal').classList.add('visible');
|
|
11818
11691
|
mountPlanReview();
|
|
11819
11692
|
const keyHandler = (e) => {
|
|
@@ -11848,7 +11721,7 @@ function closePlanModal() {
|
|
|
11848
11721
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
11849
11722
|
function openPlanInEditor() {
|
|
11850
11723
|
if (!_planSessionId) return;
|
|
11851
|
-
postAndToast(`/api/sessions/${_planSessionId}/plan/open`, {}, 'in editor');
|
|
11724
|
+
postAndToast(apiPath`/api/sessions/${_planSessionId}/plan/open`, {}, 'in editor');
|
|
11852
11725
|
}
|
|
11853
11726
|
|
|
11854
11727
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
@@ -11895,22 +11768,19 @@ let _toolStatsData = null;
|
|
|
11895
11768
|
function showToolStatsModal(sessionId) {
|
|
11896
11769
|
if (!sessionId) return;
|
|
11897
11770
|
const body = document.getElementById('tool-stats-modal-body');
|
|
11898
|
-
body.innerHTML = '<div
|
|
11771
|
+
body.innerHTML = '<div class="modal-status">Loading…</div>';
|
|
11899
11772
|
document.getElementById('tool-stats-modal').classList.add('visible');
|
|
11900
11773
|
|
|
11901
|
-
|
|
11902
|
-
|
|
11903
|
-
|
|
11904
|
-
|
|
11905
|
-
|
|
11906
|
-
|
|
11907
|
-
|
|
11908
|
-
|
|
11909
|
-
|
|
11910
|
-
|
|
11911
|
-
_toolStatsData = data;
|
|
11912
|
-
body.innerHTML = renderToolStatsBody(data);
|
|
11913
|
-
});
|
|
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
|
+
});
|
|
11914
11784
|
}
|
|
11915
11785
|
|
|
11916
11786
|
function renderToolStatsBody(data) {
|
|
@@ -11927,7 +11797,7 @@ function renderToolStatsBody(data) {
|
|
|
11927
11797
|
if (!tools?.length) {
|
|
11928
11798
|
return (
|
|
11929
11799
|
summary +
|
|
11930
|
-
'<div style="
|
|
11800
|
+
'<div class="modal-empty" style="color:var(--text-tertiary)">No tool calls recorded in this session.</div>'
|
|
11931
11801
|
);
|
|
11932
11802
|
}
|
|
11933
11803
|
|
|
@@ -12074,10 +11944,7 @@ function paneOwnOrigin(pane) {
|
|
|
12074
11944
|
|
|
12075
11945
|
async function probePaneFraming(sid, pane) {
|
|
12076
11946
|
const q = new URLSearchParams({ ancestors: paneAncestors().join(',') });
|
|
12077
|
-
const { frameable } = await paneRequest(
|
|
12078
|
-
'GET',
|
|
12079
|
-
`/api/panes/${encodeURIComponent(sid)}/${encodeURIComponent(pane.id)}/framing?${q}`,
|
|
12080
|
-
);
|
|
11947
|
+
const { frameable } = await paneRequest('GET', `${apiPath`/api/panes/${sid}/${pane.id}/framing`}?${q}`);
|
|
12081
11948
|
if (frameable === false) paneRefused.add(pane.target);
|
|
12082
11949
|
return frameable;
|
|
12083
11950
|
}
|
|
@@ -12101,10 +11968,8 @@ function applyPaneLayout(sid, layout) {
|
|
|
12101
11968
|
}
|
|
12102
11969
|
|
|
12103
11970
|
async function fetchPaneLayout(sid) {
|
|
12104
|
-
|
|
12105
|
-
|
|
12106
|
-
if (r.ok) applyPaneLayout(sid, await r.json());
|
|
12107
|
-
} catch {}
|
|
11971
|
+
const layout = await getJson(apiPath`/api/panes/${sid}`);
|
|
11972
|
+
if (layout) applyPaneLayout(sid, layout);
|
|
12108
11973
|
}
|
|
12109
11974
|
|
|
12110
11975
|
function refetchPaneLayout() {
|
|
@@ -12114,11 +11979,7 @@ function refetchPaneLayout() {
|
|
|
12114
11979
|
|
|
12115
11980
|
async function paneRequest(method, path, body) {
|
|
12116
11981
|
try {
|
|
12117
|
-
const r = await
|
|
12118
|
-
method,
|
|
12119
|
-
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
12120
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
12121
|
-
});
|
|
11982
|
+
const r = await api(path, { method, body });
|
|
12122
11983
|
const data = await r.json().catch(() => ({}));
|
|
12123
11984
|
return r.ok ? data : { error: data.error || `${method} failed (${r.status})`, code: data.code };
|
|
12124
11985
|
} catch {
|
|
@@ -12130,11 +11991,8 @@ async function paneRequest(method, path, body) {
|
|
|
12130
11991
|
const PANE_STATE_CAP = 20;
|
|
12131
11992
|
|
|
12132
11993
|
function loadPaneState(key) {
|
|
12133
|
-
|
|
12134
|
-
|
|
12135
|
-
if (map && typeof map === 'object') return map;
|
|
12136
|
-
} catch {}
|
|
12137
|
-
return {};
|
|
11994
|
+
const map = tabStore.readJson(key);
|
|
11995
|
+
return map && typeof map === 'object' ? map : {};
|
|
12138
11996
|
}
|
|
12139
11997
|
|
|
12140
11998
|
// Puts `sid` last, so the cap drops the least recent session. A null value removes it.
|
|
@@ -12142,14 +12000,12 @@ function savePaneState(key, map, sid, value) {
|
|
|
12142
12000
|
delete map[sid];
|
|
12143
12001
|
if (value != null) map[sid] = value;
|
|
12144
12002
|
const capped = Object.fromEntries(Object.entries(map).slice(-PANE_STATE_CAP));
|
|
12145
|
-
|
|
12146
|
-
sessionStorage.setItem(key, JSON.stringify(capped));
|
|
12147
|
-
} catch {}
|
|
12003
|
+
tabStore.writeJson(key, capped);
|
|
12148
12004
|
return capped;
|
|
12149
12005
|
}
|
|
12150
12006
|
|
|
12151
|
-
//
|
|
12152
|
-
const PANE_ACTIVE_KEY = '
|
|
12007
|
+
// {[sessionId]: paneId}.
|
|
12008
|
+
const PANE_ACTIVE_KEY = 'pane-active';
|
|
12153
12009
|
let paneActive = loadPaneState(PANE_ACTIVE_KEY);
|
|
12154
12010
|
|
|
12155
12011
|
function getActivePaneId(sid) {
|
|
@@ -12162,10 +12018,10 @@ function setActivePaneId(sid, id) {
|
|
|
12162
12018
|
notePairFocus(sid, id);
|
|
12163
12019
|
}
|
|
12164
12020
|
|
|
12165
|
-
//
|
|
12021
|
+
// {[sessionId]: [{l, r, f}]}; sides are pane ids or 'board', `f` the side focused
|
|
12166
12022
|
// last. A tab is in one pair at most, and the pair that holds the active tab is the split on
|
|
12167
12023
|
// screen. Clicks never change a pair; only the split keys and drags do.
|
|
12168
|
-
const PANE_PAIRS_KEY = '
|
|
12024
|
+
const PANE_PAIRS_KEY = 'pane-pairs';
|
|
12169
12025
|
const PANE_SPLIT_RATIO_KEY = 'pane-split-ratio';
|
|
12170
12026
|
let panePairs = loadPaneState(PANE_PAIRS_KEY);
|
|
12171
12027
|
const clampSplitRatio = (r) => Math.min(0.85, Math.max(0.15, r));
|
|
@@ -12302,6 +12158,8 @@ const PANE_ICONS = {
|
|
|
12302
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>',
|
|
12303
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>',
|
|
12304
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>',
|
|
12305
12163
|
warning:
|
|
12306
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>',
|
|
12307
12165
|
};
|
|
@@ -12407,9 +12265,11 @@ function markPaneSide(el, side, focused) {
|
|
|
12407
12265
|
el.classList.toggle('pane-focus', focused);
|
|
12408
12266
|
}
|
|
12409
12267
|
|
|
12268
|
+
const paneLabel = (p) => (p.kind === 'message' ? p.title : p.target);
|
|
12269
|
+
|
|
12410
12270
|
function renderPaneTabs(sid, layout, split) {
|
|
12411
|
-
// A rebuild mid-drag removes the dragged tab, and then no dragend fires.
|
|
12412
|
-
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;
|
|
12413
12273
|
const active = getActivePaneId(sid);
|
|
12414
12274
|
const state = (id) => (active === id ? ' on' : split && splitSideOf(split, id) ? ' shown' : '');
|
|
12415
12275
|
const tab = (id, cls, inner, tip) =>
|
|
@@ -12430,8 +12290,8 @@ function renderPaneTabs(sid, layout, split) {
|
|
|
12430
12290
|
return tab(
|
|
12431
12291
|
p.id,
|
|
12432
12292
|
'',
|
|
12433
|
-
`${p.kind
|
|
12434
|
-
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),
|
|
12435
12295
|
);
|
|
12436
12296
|
};
|
|
12437
12297
|
const itemHtml = ({ ids, pair }) =>
|
|
@@ -12471,7 +12331,8 @@ function markPaneFrameFocus() {
|
|
|
12471
12331
|
}
|
|
12472
12332
|
|
|
12473
12333
|
function paneCardHtml(title, text, button) {
|
|
12474
|
-
|
|
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>`;
|
|
12475
12336
|
}
|
|
12476
12337
|
|
|
12477
12338
|
// Returns the key of the view to show.
|
|
@@ -12534,11 +12395,11 @@ async function loadPaneView(sid, pane, view, scroll) {
|
|
|
12534
12395
|
}
|
|
12535
12396
|
return;
|
|
12536
12397
|
}
|
|
12537
|
-
|
|
12538
|
-
|
|
12539
|
-
|
|
12540
|
-
|
|
12541
|
-
|
|
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 } });
|
|
12542
12403
|
if (data?.kind === 'html') {
|
|
12543
12404
|
// The frame's origin is opaque, so the page restores its own scroll on load.
|
|
12544
12405
|
const restore = scroll
|
|
@@ -12551,14 +12412,9 @@ async function loadPaneView(sid, pane, view, scroll) {
|
|
|
12551
12412
|
return;
|
|
12552
12413
|
}
|
|
12553
12414
|
if (data?.kind) {
|
|
12554
|
-
const
|
|
12555
|
-
|
|
12556
|
-
doc.dataset.path = data.path;
|
|
12557
|
-
const body = document.createElement('div');
|
|
12558
|
-
body.className = 'pane-doc-body rendered-md modal-zoomable';
|
|
12415
|
+
const body = appendPaneDoc(view);
|
|
12416
|
+
body.parentElement.dataset.path = data.path;
|
|
12559
12417
|
renderPreviewContent(body, data.path, data.content, data.kind);
|
|
12560
|
-
doc.append(body, paneDocGrip(doc));
|
|
12561
|
-
view.appendChild(doc);
|
|
12562
12418
|
attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, body, null));
|
|
12563
12419
|
return;
|
|
12564
12420
|
}
|
|
@@ -12571,6 +12427,49 @@ async function loadPaneView(sid, pane, view, scroll) {
|
|
|
12571
12427
|
view.insertAdjacentHTML('beforeend', paneCardHtml(title, text, button));
|
|
12572
12428
|
}
|
|
12573
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
|
+
|
|
12574
12473
|
function onPaneFileChanged(filePath) {
|
|
12575
12474
|
for (const [key, view] of paneFrames) {
|
|
12576
12475
|
const [sid, paneId] = key.split('/');
|
|
@@ -12727,9 +12626,25 @@ function cyclePaneStrip(step) {
|
|
|
12727
12626
|
return true;
|
|
12728
12627
|
}
|
|
12729
12628
|
|
|
12730
|
-
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() {
|
|
12731
12634
|
const sid = paneSessionId();
|
|
12732
|
-
|
|
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();
|
|
12733
12648
|
if (!id || id === 'board') return false;
|
|
12734
12649
|
closePane(id);
|
|
12735
12650
|
return true;
|
|
@@ -12751,7 +12666,7 @@ async function closePane(paneId) {
|
|
|
12751
12666
|
}
|
|
12752
12667
|
unmountPane(sid, paneId);
|
|
12753
12668
|
syncPanes();
|
|
12754
|
-
const res = await paneRequest('DELETE', `/api/panes/${
|
|
12669
|
+
const res = await paneRequest('DELETE', apiPath`/api/panes/${sid}/${paneId}`);
|
|
12755
12670
|
if (res.layout) applyPaneLayout(sid, res.layout);
|
|
12756
12671
|
else if (res.code === 'no_pane') fetchPaneLayout(sid);
|
|
12757
12672
|
else showToast(res.error, 'error');
|
|
@@ -12781,21 +12696,39 @@ function reloadPane(id) {
|
|
|
12781
12696
|
function openPaneExternally(id) {
|
|
12782
12697
|
const pane = paneById(id);
|
|
12783
12698
|
if (!pane) return;
|
|
12699
|
+
if (pane.kind === 'message') {
|
|
12700
|
+
openPaneMessageDetail(pane);
|
|
12701
|
+
return;
|
|
12702
|
+
}
|
|
12784
12703
|
openLinkedDoc(pane.target, getSessionBaseDir(paneSessionId()));
|
|
12785
12704
|
}
|
|
12786
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
|
+
|
|
12787
12719
|
function copyPaneTarget(id) {
|
|
12788
12720
|
const pane = paneById(id);
|
|
12789
12721
|
if (!pane) return;
|
|
12790
|
-
|
|
12791
|
-
() => showToast(pane.kind === 'url' ? 'URL copied' : 'Path copied', 'success'),
|
|
12792
|
-
() => showToast('Copy failed', 'error'),
|
|
12793
|
-
);
|
|
12722
|
+
copyText(pane.target, pane.kind === 'url' ? 'URL' : 'path');
|
|
12794
12723
|
}
|
|
12795
12724
|
|
|
12796
|
-
function closePaneMenu() {
|
|
12725
|
+
function closePaneMenu(restore) {
|
|
12797
12726
|
document.removeEventListener('keydown', paneMenuKeydown, true);
|
|
12798
|
-
document.getElementById('pane-menu')
|
|
12727
|
+
const menu = document.getElementById('pane-menu');
|
|
12728
|
+
if (menu) {
|
|
12729
|
+
menu.remove();
|
|
12730
|
+
takeMenuFocus(restore);
|
|
12731
|
+
}
|
|
12799
12732
|
}
|
|
12800
12733
|
|
|
12801
12734
|
function paneMenuKeydown(e) {
|
|
@@ -12820,7 +12753,7 @@ async function deleteBoardTasks(onlyCompleted) {
|
|
|
12820
12753
|
});
|
|
12821
12754
|
if (!ok) return;
|
|
12822
12755
|
const q = onlyCompleted ? '?status=completed' : '';
|
|
12823
|
-
const res = await paneRequest('DELETE', `/api/tasks/${
|
|
12756
|
+
const res = await paneRequest('DELETE', `${apiPath`/api/tasks/${sid}`}${q}`);
|
|
12824
12757
|
if (res.error) {
|
|
12825
12758
|
showToast(`Failed to delete tasks: ${res.error}`, 'error');
|
|
12826
12759
|
return;
|
|
@@ -12830,6 +12763,42 @@ async function deleteBoardTasks(onlyCompleted) {
|
|
|
12830
12763
|
await refreshCurrentView();
|
|
12831
12764
|
}
|
|
12832
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
|
+
|
|
12833
12802
|
const paneMenuItem = (act, label) =>
|
|
12834
12803
|
`<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}">${label}</button>`;
|
|
12835
12804
|
|
|
@@ -12844,22 +12813,26 @@ function openPaneMenu(x, y, id) {
|
|
|
12844
12813
|
paneMenuItem('deleteCompleted', 'Delete completed tasks') +
|
|
12845
12814
|
paneMenuItem('deleteAll', 'Delete all tasks'),
|
|
12846
12815
|
);
|
|
12847
|
-
return;
|
|
12816
|
+
return true;
|
|
12848
12817
|
}
|
|
12849
12818
|
const pane = paneById(id);
|
|
12850
|
-
if (!pane) return;
|
|
12819
|
+
if (!pane) return false;
|
|
12851
12820
|
const url = pane.kind === 'url';
|
|
12821
|
+
const message = pane.kind === 'message';
|
|
12822
|
+
const label = paneLabel(pane);
|
|
12852
12823
|
showPaneMenu(
|
|
12853
12824
|
x,
|
|
12854
12825
|
y,
|
|
12855
12826
|
pane.id,
|
|
12856
12827
|
'Pane actions',
|
|
12857
|
-
`<div class="pane-menu-label" title="${escapeHtml(
|
|
12858
|
-
paneMenuItem('copy', url ? 'Copy URL' : 'Copy path') +
|
|
12859
|
-
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') +
|
|
12860
12832
|
paneMenuItem('reload', 'Reload') +
|
|
12861
12833
|
paneMenuItem('close', 'Close pane'),
|
|
12862
12834
|
);
|
|
12835
|
+
return true;
|
|
12863
12836
|
}
|
|
12864
12837
|
|
|
12865
12838
|
function showPaneMenu(x, y, id, ariaLabel, html) {
|
|
@@ -12880,6 +12853,7 @@ function showPaneMenu(x, y, id, ariaLabel, html) {
|
|
|
12880
12853
|
const PANE_MENU_ACTIONS = {
|
|
12881
12854
|
copy: copyPaneTarget,
|
|
12882
12855
|
open: openPaneExternally,
|
|
12856
|
+
rename: beginPaneRename,
|
|
12883
12857
|
reload: reloadPane,
|
|
12884
12858
|
close: closePane,
|
|
12885
12859
|
deleteCompleted: () => deleteBoardTasks(true),
|
|
@@ -12902,32 +12876,81 @@ async function addPane(raw) {
|
|
|
12902
12876
|
const t = sid && paneTargetFrom(raw);
|
|
12903
12877
|
if (!t) return;
|
|
12904
12878
|
if (t.error) {
|
|
12905
|
-
showToast(t.error);
|
|
12879
|
+
showToast(t.error, 'error');
|
|
12906
12880
|
return;
|
|
12907
12881
|
}
|
|
12908
|
-
|
|
12909
|
-
|
|
12910
|
-
|
|
12911
|
-
|
|
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);
|
|
12912
12890
|
if (!res.pane) {
|
|
12913
12891
|
showToast(res.error, 'error');
|
|
12914
|
-
return;
|
|
12892
|
+
return null;
|
|
12915
12893
|
}
|
|
12916
12894
|
setActivePaneId(sid, res.pane.id);
|
|
12917
12895
|
applyPaneLayout(sid, res.layout);
|
|
12918
|
-
|
|
12919
|
-
closePanePop();
|
|
12920
|
-
syncPanes();
|
|
12896
|
+
return sid === paneSessionId();
|
|
12921
12897
|
}
|
|
12922
12898
|
|
|
12923
|
-
|
|
12924
|
-
function openPreviewInPane() {
|
|
12925
|
-
const filePath = currentPreviewPath;
|
|
12899
|
+
function openFileInPane(filePath, closeModal) {
|
|
12926
12900
|
if (!filePath || !paneSessionId()) return;
|
|
12927
|
-
|
|
12901
|
+
closeModal();
|
|
12928
12902
|
addPane(filePath);
|
|
12929
12903
|
}
|
|
12930
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
|
+
|
|
12931
12954
|
let panePopMatches = [];
|
|
12932
12955
|
// -1 means Enter adds the typed value.
|
|
12933
12956
|
let panePopIdx = -1;
|
|
@@ -12980,7 +13003,19 @@ function closePanePop() {
|
|
|
12980
13003
|
panePop.classList.remove('visible');
|
|
12981
13004
|
}
|
|
12982
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
|
+
});
|
|
12983
13017
|
paneStrip.addEventListener('click', (e) => {
|
|
13018
|
+
if (e.target.closest('.pane-title-input')) return;
|
|
12984
13019
|
const close = e.target.closest('[data-close]');
|
|
12985
13020
|
if (close) {
|
|
12986
13021
|
closePane(close.dataset.close);
|
|
@@ -13004,7 +13039,8 @@ document.addEventListener('click', (e) => {
|
|
|
13004
13039
|
if (!menu) return;
|
|
13005
13040
|
const act = e.target.closest('.pane-menu-item')?.dataset.paneAct;
|
|
13006
13041
|
if (act) PANE_MENU_ACTIONS[act](menu.dataset.pane);
|
|
13007
|
-
if (act
|
|
13042
|
+
if (act) closePaneMenu();
|
|
13043
|
+
else if (!menu.contains(e.target)) closePaneMenu(true);
|
|
13008
13044
|
});
|
|
13009
13045
|
function focusSideOf(el) {
|
|
13010
13046
|
if (el?.classList.contains('side-l')) focusPaneSide('l');
|
|
@@ -13060,18 +13096,13 @@ paneSplitBox.addEventListener(
|
|
|
13060
13096
|
function endPaneDrag() {
|
|
13061
13097
|
if (!paneDragId) return;
|
|
13062
13098
|
paneDragId = null;
|
|
13099
|
+
markPaneDrop(null);
|
|
13063
13100
|
paneSplitBox.classList.remove('dropping');
|
|
13064
|
-
paneStrip.classList.remove('drop-hot');
|
|
13065
13101
|
for (const el of paneDrop.children) el.classList.remove('hot');
|
|
13066
13102
|
paneStrip.querySelector('.pane-tab.dragging')?.classList.remove('dragging');
|
|
13067
13103
|
syncPanes();
|
|
13068
13104
|
}
|
|
13069
13105
|
|
|
13070
|
-
function draggedPair() {
|
|
13071
|
-
const sid = paneSessionId();
|
|
13072
|
-
return (sid && paneDragId && pairOf(sid, paneDragId)) || null;
|
|
13073
|
-
}
|
|
13074
|
-
|
|
13075
13106
|
paneStrip.addEventListener('dragstart', (e) => {
|
|
13076
13107
|
const tab = e.target.closest('.pane-tab');
|
|
13077
13108
|
if (!tab || !paneSessionId() || wantsTerminal()) return;
|
|
@@ -13081,24 +13112,64 @@ paneStrip.addEventListener('dragstart', (e) => {
|
|
|
13081
13112
|
tab.classList.add('dragging');
|
|
13082
13113
|
paneSplitBox.classList.add('dropping');
|
|
13083
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
|
+
|
|
13084
13154
|
paneStrip.addEventListener('dragend', endPaneDrag);
|
|
13085
13155
|
paneStrip.addEventListener('dragover', (e) => {
|
|
13086
|
-
|
|
13087
|
-
|
|
13088
|
-
|
|
13156
|
+
const action = paneDropAction(e);
|
|
13157
|
+
markPaneDrop(action);
|
|
13158
|
+
if (action) e.preventDefault();
|
|
13089
13159
|
});
|
|
13090
13160
|
paneStrip.addEventListener('dragleave', (e) => {
|
|
13091
|
-
if (!paneStrip.contains(e.relatedTarget))
|
|
13161
|
+
if (!paneStrip.contains(e.relatedTarget)) markPaneDrop(null);
|
|
13092
13162
|
});
|
|
13093
13163
|
paneStrip.addEventListener('drop', (e) => {
|
|
13094
|
-
const
|
|
13095
|
-
if (!
|
|
13164
|
+
const action = paneDropAction(e);
|
|
13165
|
+
if (!action) return;
|
|
13096
13166
|
e.preventDefault();
|
|
13097
|
-
const sid =
|
|
13098
|
-
const shown = !!splitSideOf(pair, getActivePaneId(sid));
|
|
13167
|
+
const { sid, pair } = action;
|
|
13168
|
+
const shown = pair && !!splitSideOf(pair, getActivePaneId(sid));
|
|
13099
13169
|
const keep = shown ? pair[otherSide(splitSideOf(pair, paneDragId))] : null;
|
|
13100
13170
|
endPaneDrag();
|
|
13101
|
-
|
|
13171
|
+
if (action.kind === 'move') movePaneItem(action);
|
|
13172
|
+
else unpairPanes(sid, pair, keep);
|
|
13102
13173
|
});
|
|
13103
13174
|
paneDrop.addEventListener('dragover', (e) => {
|
|
13104
13175
|
if (!paneDragId) return;
|
|
@@ -13215,8 +13286,7 @@ panePop.addEventListener('keydown', (e) => {
|
|
|
13215
13286
|
// leaving the session view or switching sessions drops only the socket; coming back
|
|
13216
13287
|
// reattaches and the server replays the screen.
|
|
13217
13288
|
const TERMINAL_TOKEN_KEY = 'terminal-token';
|
|
13218
|
-
|
|
13219
|
-
const TERMINAL_FOCUS_KEY = nsKey('terminal-focus');
|
|
13289
|
+
const TERMINAL_FOCUS_KEY = 'terminal-focus';
|
|
13220
13290
|
const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
13221
13291
|
const TERMINAL_MODES_KEY = 'terminal-sessions';
|
|
13222
13292
|
const TERMINAL_FONT_KEY = 'terminal-font-size';
|
|
@@ -13269,17 +13339,11 @@ function readTerminalToken() {
|
|
|
13269
13339
|
history.replaceState(null, '', location.pathname + location.search);
|
|
13270
13340
|
return m[1];
|
|
13271
13341
|
}
|
|
13272
|
-
|
|
13273
|
-
return sessionStorage.getItem(TERMINAL_TOKEN_KEY);
|
|
13274
|
-
} catch (_) {
|
|
13275
|
-
return null;
|
|
13276
|
-
}
|
|
13342
|
+
return tabStore.getItem(TERMINAL_TOKEN_KEY);
|
|
13277
13343
|
}
|
|
13278
13344
|
|
|
13279
13345
|
function storeTerminalToken(token) {
|
|
13280
|
-
|
|
13281
|
-
sessionStorage.setItem(TERMINAL_TOKEN_KEY, token);
|
|
13282
|
-
} catch (_) {}
|
|
13346
|
+
tabStore.setItem(TERMINAL_TOKEN_KEY, token);
|
|
13283
13347
|
}
|
|
13284
13348
|
|
|
13285
13349
|
let tokenRefresh = null;
|
|
@@ -13313,9 +13377,7 @@ function setTerminalMode(sessionId, on) {
|
|
|
13313
13377
|
const modes = terminalModes();
|
|
13314
13378
|
if (on) modes.add(sessionId);
|
|
13315
13379
|
else modes.delete(sessionId);
|
|
13316
|
-
|
|
13317
|
-
store.setItem(TERMINAL_MODES_KEY, JSON.stringify([...modes].slice(-50)));
|
|
13318
|
-
} catch (_) {}
|
|
13380
|
+
store.writeJson(TERMINAL_MODES_KEY, [...modes].slice(-50));
|
|
13319
13381
|
}
|
|
13320
13382
|
|
|
13321
13383
|
function wantsTerminal() {
|
|
@@ -13378,19 +13440,14 @@ function terminalShortcut(e, probe = false) {
|
|
|
13378
13440
|
}
|
|
13379
13441
|
|
|
13380
13442
|
function readSwapPair() {
|
|
13381
|
-
|
|
13382
|
-
|
|
13383
|
-
if (Array.isArray(pair) && pair.length === 2) return pair;
|
|
13384
|
-
} catch {}
|
|
13385
|
-
return [null, null];
|
|
13443
|
+
const pair = readStoredList(SWAP_KEY);
|
|
13444
|
+
return pair.length === 2 ? pair : [null, null];
|
|
13386
13445
|
}
|
|
13387
13446
|
|
|
13388
13447
|
function setSwapPair(last, previous) {
|
|
13389
13448
|
lastSessionId = last;
|
|
13390
13449
|
previousSessionId = previous;
|
|
13391
|
-
|
|
13392
|
-
store.setItem(SWAP_KEY, JSON.stringify([last, previous]));
|
|
13393
|
-
} catch {}
|
|
13450
|
+
store.writeJson(SWAP_KEY, [last, previous]);
|
|
13394
13451
|
}
|
|
13395
13452
|
|
|
13396
13453
|
async function swapToPreviousSession() {
|
|
@@ -13887,18 +13944,13 @@ window.addEventListener('focus', () => {
|
|
|
13887
13944
|
});
|
|
13888
13945
|
});
|
|
13889
13946
|
window.addEventListener('pagehide', () => {
|
|
13890
|
-
|
|
13891
|
-
|
|
13892
|
-
sessionStorage.setItem(TERMINAL_FOCUS_KEY, currentSessionId);
|
|
13893
|
-
} else sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
|
|
13894
|
-
} catch (_) {}
|
|
13947
|
+
if (termState.attached && termState.shown && terminalHadFocus) tabStore.setItem(TERMINAL_FOCUS_KEY, currentSessionId);
|
|
13948
|
+
else tabStore.removeItem(TERMINAL_FOCUS_KEY);
|
|
13895
13949
|
});
|
|
13896
13950
|
|
|
13897
13951
|
function restoreTerminalFocus(sessionId) {
|
|
13898
|
-
|
|
13899
|
-
|
|
13900
|
-
sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
|
|
13901
|
-
} catch (_) {}
|
|
13952
|
+
if (tabStore.getItem(TERMINAL_FOCUS_KEY) === sessionId) termState.focusNext = true;
|
|
13953
|
+
tabStore.removeItem(TERMINAL_FOCUS_KEY);
|
|
13902
13954
|
}
|
|
13903
13955
|
|
|
13904
13956
|
// The terminal stands in for the board, so it takes the board zone however focus arrives
|
|
@@ -14093,14 +14145,16 @@ function endTerminalSession() {
|
|
|
14093
14145
|
}
|
|
14094
14146
|
|
|
14095
14147
|
// Detaching first means no exit message reaches the pane, so no Resume prompt shows before the board.
|
|
14096
|
-
|
|
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) {
|
|
14097
14150
|
if (id === termState.sessionId) {
|
|
14098
14151
|
if (terminalPaneFocused()) leaveTerminalPane();
|
|
14099
14152
|
detachTerminal();
|
|
14100
14153
|
}
|
|
14101
14154
|
setTerminalMode(id, false);
|
|
14102
14155
|
syncTerminal();
|
|
14103
|
-
|
|
14156
|
+
await terminalFetch(apiPath`/api/terminals/${id}`, 'DELETE').catch(() => {});
|
|
14157
|
+
dropPlaceholder(id);
|
|
14104
14158
|
}
|
|
14105
14159
|
|
|
14106
14160
|
function openTerminalManager() {
|
|
@@ -14133,13 +14187,9 @@ async function pollTerminalProcStats() {
|
|
|
14133
14187
|
if (!isOnScreen()) return;
|
|
14134
14188
|
let cck;
|
|
14135
14189
|
let host;
|
|
14136
|
-
|
|
14137
|
-
|
|
14138
|
-
|
|
14139
|
-
({ cck, host, terminals: terminalProcStats } = await res.json());
|
|
14140
|
-
} catch (_) {
|
|
14141
|
-
return;
|
|
14142
|
-
}
|
|
14190
|
+
const stats = await getJson('/api/terminals/stats', null, { cache: 'no-store' });
|
|
14191
|
+
if (!stats) return;
|
|
14192
|
+
({ cck, host, terminals: terminalProcStats } = stats);
|
|
14143
14193
|
const parts = [cck && `cck ${formatTerminalProc(cck)}`, host && `terminal host ${formatTerminalProc(host)}`];
|
|
14144
14194
|
document.getElementById('terminal-manager-stats').textContent = parts.filter(Boolean).join(' · ');
|
|
14145
14195
|
for (const el of document.querySelectorAll('#terminal-manager-body [data-proc]')) {
|
|
@@ -14200,35 +14250,22 @@ async function renderTerminalManager() {
|
|
|
14200
14250
|
body.querySelectorAll('[data-end]').forEach((b) => {
|
|
14201
14251
|
b.onclick = async () => {
|
|
14202
14252
|
b.disabled = true;
|
|
14203
|
-
await
|
|
14253
|
+
await closeTerminalSession(b.dataset.end);
|
|
14204
14254
|
renderTerminalManager();
|
|
14205
14255
|
};
|
|
14206
14256
|
});
|
|
14207
14257
|
}
|
|
14208
14258
|
|
|
14209
14259
|
async function terminalFetch(url, method, body) {
|
|
14210
|
-
const send = () =>
|
|
14211
|
-
fetch(url, {
|
|
14212
|
-
method,
|
|
14213
|
-
headers: {
|
|
14214
|
-
'X-Terminal-Token': terminalToken || '',
|
|
14215
|
-
...(body && { 'Content-Type': 'application/json' }),
|
|
14216
|
-
},
|
|
14217
|
-
...(body && { body: JSON.stringify(body) }),
|
|
14218
|
-
});
|
|
14260
|
+
const send = () => api(url, { method, body, headers: { 'X-Terminal-Token': terminalToken || '' } });
|
|
14219
14261
|
const res = await send();
|
|
14220
14262
|
return res.status === 401 && (await refreshTerminalToken()) ? send() : res;
|
|
14221
14263
|
}
|
|
14222
14264
|
|
|
14223
|
-
async function endTerminal(id) {
|
|
14224
|
-
await terminalFetch(`/api/terminals/${encodeURIComponent(id)}`, 'DELETE').catch(() => {});
|
|
14225
|
-
dropPlaceholder(id);
|
|
14226
|
-
}
|
|
14227
|
-
|
|
14228
14265
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
14229
14266
|
async function endAllTerminals() {
|
|
14230
14267
|
const buttons = document.querySelectorAll('#terminal-manager-body [data-end]');
|
|
14231
|
-
await Promise.all([...buttons].map((b) =>
|
|
14268
|
+
await Promise.all([...buttons].map((b) => closeTerminalSession(b.dataset.end)));
|
|
14232
14269
|
renderTerminalManager();
|
|
14233
14270
|
}
|
|
14234
14271
|
|
|
@@ -14294,23 +14331,21 @@ function mergePlaceholders(list) {
|
|
|
14294
14331
|
}
|
|
14295
14332
|
|
|
14296
14333
|
async function loadDispatches() {
|
|
14297
|
-
|
|
14298
|
-
|
|
14299
|
-
|
|
14300
|
-
|
|
14301
|
-
|
|
14302
|
-
|
|
14303
|
-
|
|
14304
|
-
|
|
14305
|
-
|
|
14306
|
-
|
|
14307
|
-
|
|
14308
|
-
|
|
14309
|
-
|
|
14310
|
-
|
|
14311
|
-
|
|
14312
|
-
}
|
|
14313
|
-
} 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
|
+
}
|
|
14314
14349
|
}
|
|
14315
14350
|
|
|
14316
14351
|
async function onDispatchUpdate() {
|
|
@@ -14377,8 +14412,10 @@ async function restorePendingSessions() {
|
|
|
14377
14412
|
}
|
|
14378
14413
|
|
|
14379
14414
|
async function loadTerminals() {
|
|
14380
|
-
const res = await
|
|
14381
|
-
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 || [];
|
|
14382
14419
|
runningTerminals = new Set(list.map((t) => t.id));
|
|
14383
14420
|
pushTerminalClaims();
|
|
14384
14421
|
renderActivityChip();
|
|
@@ -14767,7 +14804,7 @@ if ('serviceWorker' in navigator) {
|
|
|
14767
14804
|
|
|
14768
14805
|
//#region INIT
|
|
14769
14806
|
loadTheme();
|
|
14770
|
-
if (store.getItem('sessions-
|
|
14807
|
+
if (store.getItem(sectionCollapsedKey('sessions-filters')) === 'true') {
|
|
14771
14808
|
document.getElementById('sessions-filters').classList.add('collapsed');
|
|
14772
14809
|
document.getElementById('sessions-chevron').classList.add('rotated');
|
|
14773
14810
|
}
|
|
@@ -14806,27 +14843,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
14806
14843
|
});
|
|
14807
14844
|
|
|
14808
14845
|
loadSidebarState();
|
|
14809
|
-
|
|
14810
|
-
const cg = JSON.parse(store.getItem('collapsedGroups') || '[]');
|
|
14811
|
-
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
14812
|
-
cg.forEach((p) => collapsedProjectGroups.add(p));
|
|
14813
|
-
} catch (_) {}
|
|
14846
|
+
for (const p of readStoredList(COLLAPSED_GROUPS_KEY)) collapsedProjectGroups.add(p);
|
|
14814
14847
|
loadSessionGroups();
|
|
14815
14848
|
initSessionGroupsDnd();
|
|
14816
14849
|
initSessionPicker();
|
|
14817
14850
|
initProjectPicker();
|
|
14818
|
-
|
|
14819
|
-
const af = JSON.parse(store.getItem('activityFilter') || '[]');
|
|
14820
|
-
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
14821
|
-
af.forEach((k) => activityFilter.add(k));
|
|
14822
|
-
} catch (_) {}
|
|
14851
|
+
for (const k of readStoredList(ACTIVITY_FILTER_KEY)) activityFilter.add(k);
|
|
14823
14852
|
initSidebarResize();
|
|
14824
14853
|
applyModalZoom();
|
|
14825
14854
|
loadModalFullscreen();
|
|
14826
14855
|
initModalResize();
|
|
14827
14856
|
loadPanelWidths();
|
|
14828
|
-
initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width'
|
|
14829
|
-
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');
|
|
14830
14859
|
initMessagePanelFull();
|
|
14831
14860
|
|
|
14832
14861
|
const msgContentEl = document.getElementById('message-panel-content');
|
|
@@ -14939,12 +14968,7 @@ function pluginWarning(plugin) {
|
|
|
14939
14968
|
btn.title = `${problem} (${plugin.configDir}).\n${fix}`;
|
|
14940
14969
|
btn.setAttribute('aria-label', problem);
|
|
14941
14970
|
if (plugin.state !== 'disabled') {
|
|
14942
|
-
btn.addEventListener('click', () =>
|
|
14943
|
-
navigator.clipboard
|
|
14944
|
-
.writeText(plugin.installCommand)
|
|
14945
|
-
.then(() => showToast('Install command copied', 'success'))
|
|
14946
|
-
.catch(() => showToast('Could not copy the command', 'error')),
|
|
14947
|
-
);
|
|
14971
|
+
btn.addEventListener('click', () => copyText(plugin.installCommand, 'install command'));
|
|
14948
14972
|
}
|
|
14949
14973
|
return btn;
|
|
14950
14974
|
}
|
|
@@ -14953,18 +14977,17 @@ function refreshRateLimits() {
|
|
|
14953
14977
|
if (footerState.timer) return;
|
|
14954
14978
|
footerState.timer = setTimeout(() => {
|
|
14955
14979
|
footerState.timer = null;
|
|
14956
|
-
|
|
14957
|
-
|
|
14958
|
-
|
|
14959
|
-
|
|
14960
|
-
|
|
14961
|
-
|
|
14962
|
-
|
|
14963
|
-
|
|
14964
|
-
|
|
14965
|
-
|
|
14966
|
-
|
|
14967
|
-
.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
|
+
});
|
|
14968
14991
|
}, 1500);
|
|
14969
14992
|
}
|
|
14970
14993
|
|
|
@@ -14979,15 +15002,12 @@ function scheduleRateLimitReset(rl) {
|
|
|
14979
15002
|
const ms = Math.min(...resets) * 1000 - Date.now() + 1000;
|
|
14980
15003
|
footerState.resetTimer = setTimeout(refreshRateLimits, Math.min(Math.max(ms, 0), 2 ** 31 - 1));
|
|
14981
15004
|
}
|
|
14982
|
-
|
|
14983
|
-
.
|
|
14984
|
-
.
|
|
14985
|
-
|
|
14986
|
-
|
|
14987
|
-
|
|
14988
|
-
refreshRateLimits();
|
|
14989
|
-
})
|
|
14990
|
-
.catch(() => {});
|
|
15005
|
+
getJson('/api/version', {}).then((d) => {
|
|
15006
|
+
footerState.version = d.version;
|
|
15007
|
+
footerState.plugin = d.plugin ?? null;
|
|
15008
|
+
renderSidebarFooter(null);
|
|
15009
|
+
refreshRateLimits();
|
|
15010
|
+
});
|
|
14991
15011
|
|
|
14992
15012
|
const urlState = getUrlState();
|
|
14993
15013
|
const lastView = loadLastView();
|
|
@@ -15014,12 +15034,10 @@ if (urlState.search) {
|
|
|
15014
15034
|
document.getElementById('boot-verb').textContent = pickRandom(verbs);
|
|
15015
15035
|
}
|
|
15016
15036
|
|
|
15017
|
-
|
|
15018
|
-
.then((r) => r.json())
|
|
15037
|
+
getJson('/api/config')
|
|
15019
15038
|
.then((c) => {
|
|
15020
|
-
appConfig = c;
|
|
15039
|
+
if (c) appConfig = c;
|
|
15021
15040
|
})
|
|
15022
|
-
.catch(() => {})
|
|
15023
15041
|
.then(restorePendingSessions)
|
|
15024
15042
|
.then(() =>
|
|
15025
15043
|
fetchSessions(
|