claude-code-kanban 5.1.1 → 5.3.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 +3 -2
- package/lib/auto-compact.js +42 -0
- package/lib/claude-dir.js +7 -1
- package/lib/claude-settings.js +21 -0
- package/lib/dispatch.js +11 -4
- package/lib/net-guard.js +41 -14
- package/lib/proc-stats.js +82 -0
- package/lib/retention.js +140 -0
- package/lib/session-events.js +30 -7
- package/lib/terminal.js +39 -6
- package/lib/worktrees.js +105 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +15 -8
- package/plugin/plugins/claude-code-kanban/monitors.json +3 -3
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +2 -2
- package/plugin/plugins/claude-code-kanban/skills/{kanban-dispatch → dispatch}/SKILL.md +3 -3
- package/plugin/plugins/claude-code-kanban/skills/{kanban-follow → follow}/SKILL.md +17 -9
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -2
- package/public/app.js +1332 -490
- package/public/fonts/OFL-IBM-Plex.txt +93 -0
- package/public/fonts/OFL-Playfair-Display.txt +93 -0
- package/public/fonts/fonts.css +244 -0
- package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
- package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
- package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
- package/public/index.html +27 -17
- package/public/project-match.js +6 -4
- package/public/style.css +528 -116
- package/public/terminal-frame.js +341 -0
- package/public/terminal.html +27 -0
- package/public/vendor/claude-hub-sdk.js +13 -3
- package/server.js +204 -60
- package/skill-guides/dispatch.md +5 -5
package/public/app.js
CHANGED
|
@@ -52,7 +52,12 @@ const collapsedProjectGroups = new Set();
|
|
|
52
52
|
const SECTION_GROUPS = '__section_groups__';
|
|
53
53
|
const SECTION_PROJECTS = '__section_projects__';
|
|
54
54
|
const SECTION_SESSIONS = '__section_sessions__';
|
|
55
|
-
|
|
55
|
+
// Project paths and session ids in Active-view sidebar order, kept across reloads so live
|
|
56
|
+
// activity does not reshuffle the list. Each holds only what the loaded list still has.
|
|
57
|
+
const PROJECT_ORDER_KEY = 'project-order';
|
|
58
|
+
const SESSION_ORDER_KEY = 'session-order';
|
|
59
|
+
let stableGroupOrder = readStoredList(PROJECT_ORDER_KEY);
|
|
60
|
+
let sessionOrder = readStoredList(SESSION_ORDER_KEY);
|
|
56
61
|
let sessionGroups = []; // user-named groups: [{id, name, color, members:[{type,ref}]}]
|
|
57
62
|
// Sessions the user pulled out of their dispatch placement; the server's group map is not theirs to edit.
|
|
58
63
|
let sgReleased = new Set();
|
|
@@ -260,7 +265,7 @@ let lastTasksHash = '';
|
|
|
260
265
|
//#endregion
|
|
261
266
|
|
|
262
267
|
//#region DATA_FETCHING
|
|
263
|
-
async function fetchSessions(includeTasks = true) {
|
|
268
|
+
async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
|
|
264
269
|
try {
|
|
265
270
|
const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
|
|
266
271
|
if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
|
|
@@ -269,7 +274,7 @@ async function fetchSessions(includeTasks = true) {
|
|
|
269
274
|
// The focused session must come back whatever the filters say: renderSession and the
|
|
270
275
|
// info modal both look it up in `sessions` and bail when it is absent, so a session
|
|
271
276
|
// opened from outside the current filter would leave the view on the previous one.
|
|
272
|
-
const includeParam =
|
|
277
|
+
const includeParam = focusId ? `&include=${encodeURIComponent(focusId)}` : '';
|
|
273
278
|
const projectParam =
|
|
274
279
|
filterProject === '__recent__'
|
|
275
280
|
? `&recentHours=${RECENT_PROJECT_HOURS}`
|
|
@@ -587,6 +592,9 @@ let lastCurrentTasksHash = '';
|
|
|
587
592
|
async function fetchTasks(sessionId) {
|
|
588
593
|
try {
|
|
589
594
|
const refresh = viewMode === 'session' && sessionId === currentSessionId;
|
|
595
|
+
// The PTY already runs, so the attach need not wait for the session fetch and the render after it.
|
|
596
|
+
if (!refresh && termState.sessionId !== sessionId && runningTerminals.has(sessionId) && wantsTerminalFor(sessionId))
|
|
597
|
+
openTerminal(sessionId, terminalOpenMode(sessionId));
|
|
590
598
|
viewMode = 'session';
|
|
591
599
|
document.getElementById('message-toggle')?.style.removeProperty('display');
|
|
592
600
|
const res = await fetch(`/api/sessions/${sessionId}`);
|
|
@@ -1694,6 +1702,8 @@ let pinnedCollapsed = false;
|
|
|
1694
1702
|
|
|
1695
1703
|
const PIN_SVG =
|
|
1696
1704
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
|
|
1705
|
+
const DETAIL_EDIT_SVG =
|
|
1706
|
+
'<svg class="detail-edit-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
|
|
1697
1707
|
const MSG_ICON_USER =
|
|
1698
1708
|
'<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
|
|
1699
1709
|
const MSG_ICON_ASSISTANT =
|
|
@@ -1727,6 +1737,8 @@ const ICON_AGENT_ACTIVE =
|
|
|
1727
1737
|
const ICON_TERMINAL =
|
|
1728
1738
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>';
|
|
1729
1739
|
const TERMINAL_TITLE = 'Running in a terminal here';
|
|
1740
|
+
const ICON_DISPATCHED =
|
|
1741
|
+
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/></svg>';
|
|
1730
1742
|
const ICON_CHAT =
|
|
1731
1743
|
'<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>';
|
|
1732
1744
|
const TOOL_ICONS = {
|
|
@@ -1974,16 +1986,24 @@ function offloadSessionPin(sessionId) {
|
|
|
1974
1986
|
}).catch(() => {});
|
|
1975
1987
|
}
|
|
1976
1988
|
|
|
1989
|
+
function clearSessionPin(sessionId) {
|
|
1990
|
+
pinnedSessionIds.delete(sessionId);
|
|
1991
|
+
stickySessionIds.delete(sessionId);
|
|
1992
|
+
deferredPinPlacement.delete(sessionId);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function unpinSession(sessionId) {
|
|
1996
|
+
clearSessionPin(sessionId);
|
|
1997
|
+
savePinnedSessions();
|
|
1998
|
+
offloadSessionPin(sessionId);
|
|
1999
|
+
renderSessions();
|
|
2000
|
+
}
|
|
2001
|
+
|
|
1977
2002
|
function toggleSessionPin(sessionId) {
|
|
1978
|
-
if (pinnedSessionIds.has(sessionId))
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
} else {
|
|
1983
|
-
pinnedSessionIds.add(sessionId);
|
|
1984
|
-
if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
|
|
1985
|
-
expandPinnedFor(sessionId);
|
|
1986
|
-
}
|
|
2003
|
+
if (pinnedSessionIds.has(sessionId)) return unpinSession(sessionId);
|
|
2004
|
+
pinnedSessionIds.add(sessionId);
|
|
2005
|
+
if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
|
|
2006
|
+
expandPinnedFor(sessionId);
|
|
1987
2007
|
savePinnedSessions();
|
|
1988
2008
|
offloadSessionPin(sessionId);
|
|
1989
2009
|
renderSessions();
|
|
@@ -1991,9 +2011,7 @@ function toggleSessionPin(sessionId) {
|
|
|
1991
2011
|
|
|
1992
2012
|
function toggleSessionSticky(sessionId) {
|
|
1993
2013
|
if (stickySessionIds.has(sessionId)) {
|
|
1994
|
-
|
|
1995
|
-
pinnedSessionIds.delete(sessionId);
|
|
1996
|
-
deferredPinPlacement.delete(sessionId);
|
|
2014
|
+
clearSessionPin(sessionId);
|
|
1997
2015
|
} else {
|
|
1998
2016
|
pinnedSessionIds.add(sessionId);
|
|
1999
2017
|
stickySessionIds.add(sessionId);
|
|
@@ -2013,9 +2031,7 @@ function isPlacedSticky(id) {
|
|
|
2013
2031
|
|
|
2014
2032
|
function handleSessionPinEvent({ id, state }) {
|
|
2015
2033
|
if (!id) return;
|
|
2016
|
-
|
|
2017
|
-
stickySessionIds.delete(id);
|
|
2018
|
-
deferredPinPlacement.delete(id);
|
|
2034
|
+
clearSessionPin(id);
|
|
2019
2035
|
if (state === 'pinned') pinnedSessionIds.add(id);
|
|
2020
2036
|
if (state === 'sticky') {
|
|
2021
2037
|
pinnedSessionIds.add(id);
|
|
@@ -2037,6 +2053,7 @@ function isAnyPinned(sessionId) {
|
|
|
2037
2053
|
}
|
|
2038
2054
|
|
|
2039
2055
|
function _renderPinToDetail(pin) {
|
|
2056
|
+
unmountMsgReview();
|
|
2040
2057
|
const body = document.getElementById('msg-detail-body');
|
|
2041
2058
|
const agentBtn = document.getElementById('msg-detail-agent-btn');
|
|
2042
2059
|
agentBtn.style.display = 'none';
|
|
@@ -2252,11 +2269,10 @@ function showMsgDetail(idx) {
|
|
|
2252
2269
|
body.innerHTML = userExtras || '<em>No content</em>';
|
|
2253
2270
|
}
|
|
2254
2271
|
}
|
|
2255
|
-
const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
|
|
2256
|
-
autoSizeModal(modal, body);
|
|
2257
|
-
modal.classList.toggle('live', msgDetailFollowLatest);
|
|
2258
2272
|
const overlay = document.getElementById('msg-detail-modal');
|
|
2259
|
-
overlay.
|
|
2273
|
+
const modal = overlay.querySelector('.modal');
|
|
2274
|
+
autoSizeModal(modal, body);
|
|
2275
|
+
setMsgDetailFollow(msgDetailFollowLatest);
|
|
2260
2276
|
|
|
2261
2277
|
const meta = [formatDate(m.timestamp)];
|
|
2262
2278
|
if (m.model) meta.unshift(m.model);
|
|
@@ -2265,9 +2281,34 @@ function showMsgDetail(idx) {
|
|
|
2265
2281
|
currentPinDetailId = null;
|
|
2266
2282
|
updateMsgDetailPinState();
|
|
2267
2283
|
overlay.classList.add('visible');
|
|
2284
|
+
mountReplyReview(m, body, modal);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
function setMsgDetailFollow(on) {
|
|
2288
|
+
msgDetailFollowLatest = on;
|
|
2289
|
+
const overlay = document.getElementById('msg-detail-modal');
|
|
2290
|
+
overlay.classList.toggle('live-overlay', on);
|
|
2291
|
+
overlay.querySelector('.modal').classList.toggle('live', on);
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
function unmountMsgReview() {
|
|
2295
|
+
unmountReview(document.getElementById('msg-review-panel'));
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
// Only the main session's replies: the agent has them in context, so a quote is enough.
|
|
2299
|
+
function mountReplyReview(m, body, modal) {
|
|
2300
|
+
if (m.type !== 'assistant' || agentLogMode || !m.timestamp) return unmountMsgReview();
|
|
2301
|
+
mountReview({
|
|
2302
|
+
contentEl: body,
|
|
2303
|
+
panelEl: document.getElementById('msg-review-panel'),
|
|
2304
|
+
hostEl: modal,
|
|
2305
|
+
source: { kind: 'reply', id: m.timestamp, label: `your reply at ${new Date(m.timestamp).toLocaleTimeString()}` },
|
|
2306
|
+
onComment: () => setMsgDetailFollow(false),
|
|
2307
|
+
});
|
|
2268
2308
|
}
|
|
2269
2309
|
|
|
2270
2310
|
function closeMsgDetailModal() {
|
|
2311
|
+
unmountMsgReview();
|
|
2271
2312
|
hideModalOverlay('msg-detail-modal');
|
|
2272
2313
|
msgDetailFollowLatest = false;
|
|
2273
2314
|
waitingDetailAutoOpened = false;
|
|
@@ -2456,14 +2497,21 @@ let _manualRefreshing = false;
|
|
|
2456
2497
|
|
|
2457
2498
|
//#region TOAST
|
|
2458
2499
|
// `action` ({label, onClick}) adds a button and keeps the toast up long enough to reach it.
|
|
2459
|
-
function showToast(msg, type, action) {
|
|
2500
|
+
function showToast(msg, type, action, hint) {
|
|
2460
2501
|
const el = document.getElementById('toast');
|
|
2461
2502
|
clearTimeout(_toastTimer);
|
|
2462
2503
|
el.style.transition = 'none';
|
|
2463
2504
|
el.classList.remove('visible', 'toast-success', 'toast-error', 'toast-info', 'has-action');
|
|
2464
2505
|
void el.offsetHeight;
|
|
2465
2506
|
el.style.transition = '';
|
|
2466
|
-
|
|
2507
|
+
if (hint) {
|
|
2508
|
+
const text = document.createElement('div');
|
|
2509
|
+
const hintEl = document.createElement('div');
|
|
2510
|
+
hintEl.className = 'toast-hint';
|
|
2511
|
+
hintEl.textContent = hint;
|
|
2512
|
+
text.append(msg, hintEl);
|
|
2513
|
+
el.replaceChildren(text);
|
|
2514
|
+
} else el.textContent = msg;
|
|
2467
2515
|
const hide = () => el.classList.remove('visible', 'has-action');
|
|
2468
2516
|
if (action) {
|
|
2469
2517
|
const btn = document.createElement('button');
|
|
@@ -2877,8 +2925,9 @@ function autoSizeModal(modal, body) {
|
|
|
2877
2925
|
// hasPre is already computed and short-circuits the textContent walk, which is
|
|
2878
2926
|
// no longer cheap now that colourised output puts thousands of spans in there.
|
|
2879
2927
|
const desired = hasTable ? 1100 : hasPre || body.textContent.length > 2000 ? 960 : 860;
|
|
2880
|
-
const
|
|
2881
|
-
|
|
2928
|
+
const style = getComputedStyle(modal);
|
|
2929
|
+
const current = (parseFloat(style.maxWidth) || 0) - (parseFloat(style.getPropertyValue('--review-extra')) || 0);
|
|
2930
|
+
if (desired > current) modal.style.maxWidth = `calc(${desired}px + var(--review-extra, 0px))`;
|
|
2882
2931
|
}
|
|
2883
2932
|
|
|
2884
2933
|
function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
|
|
@@ -3387,7 +3436,7 @@ function getFilteredSessions() {
|
|
|
3387
3436
|
// above already makes, for the same reason.
|
|
3388
3437
|
filteredSessions = filteredSessions.filter(
|
|
3389
3438
|
(s) =>
|
|
3390
|
-
matchesProjectFilter(s.project) ||
|
|
3439
|
+
matchesProjectFilter(s.project, s.worktree?.repo) ||
|
|
3391
3440
|
s.id === currentSessionId ||
|
|
3392
3441
|
(filterProject === '__recent__' && isAnyPinned(s.id)),
|
|
3393
3442
|
);
|
|
@@ -3449,6 +3498,39 @@ function getFilteredSessions() {
|
|
|
3449
3498
|
return filteredSessions;
|
|
3450
3499
|
}
|
|
3451
3500
|
|
|
3501
|
+
function saveOrder(key, prev, next) {
|
|
3502
|
+
if (next.length === prev.length && next.every((id, i) => id === prev[i])) return prev;
|
|
3503
|
+
try {
|
|
3504
|
+
store.setItem(key, JSON.stringify(next));
|
|
3505
|
+
} catch (_) {}
|
|
3506
|
+
return next;
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
// An entry keeps its slot while `loaded` has it; one not seen before enters at the top.
|
|
3510
|
+
function mergeOrder(key, prev, loaded, incoming) {
|
|
3511
|
+
const kept = prev.filter((id) => loaded.has(id));
|
|
3512
|
+
const known = new Set(kept);
|
|
3513
|
+
return saveOrder(key, prev, [...incoming.filter((id) => !known.has(id)), ...kept]);
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3516
|
+
// Only the Active view groups into blocks; the other views stay in recency order.
|
|
3517
|
+
function applyStableSessionOrder(list) {
|
|
3518
|
+
if (sessionFilter !== 'active' || sessions.length === 0) return list;
|
|
3519
|
+
const loaded = new Set(sessions.map((s) => s.id));
|
|
3520
|
+
const ids = list.map((s) => s.id);
|
|
3521
|
+
sessionOrder = mergeOrder(SESSION_ORDER_KEY, sessionOrder, loaded, ids);
|
|
3522
|
+
const rank = new Map(sessionOrder.map((id, i) => [id, i]));
|
|
3523
|
+
return [...list].sort((a, b) => rank.get(a.id) - rank.get(b.id));
|
|
3524
|
+
}
|
|
3525
|
+
|
|
3526
|
+
function insertNear(arr, isRef, item, after) {
|
|
3527
|
+
const at = arr.findIndex(isRef);
|
|
3528
|
+
if (at < 0) return null;
|
|
3529
|
+
const out = [...arr];
|
|
3530
|
+
out.splice(at + (after ? 1 : 0), 0, item);
|
|
3531
|
+
return out;
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3452
3534
|
// A live refresh would replace the zen panel's link-a-file input mid-type, the same way it
|
|
3453
3535
|
// would swallow a half-typed group name. Off zen there is no panel, so skip the DOM probe.
|
|
3454
3536
|
function zenPanelIsEditing() {
|
|
@@ -3465,7 +3547,7 @@ function renderSessions() {
|
|
|
3465
3547
|
// Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
|
|
3466
3548
|
// and still offers everything, or there would be no way to switch sessions without leaving zen.
|
|
3467
3549
|
const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
3468
|
-
const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : getFilteredSessions();
|
|
3550
|
+
const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : applyStableSessionOrder(getFilteredSessions());
|
|
3469
3551
|
|
|
3470
3552
|
if (filteredSessions.length === 0) {
|
|
3471
3553
|
let emptyMsg = 'No sessions found';
|
|
@@ -3507,15 +3589,6 @@ function renderSessions() {
|
|
|
3507
3589
|
const projectHtml = renderProjectIdentity(session);
|
|
3508
3590
|
|
|
3509
3591
|
const gitBranch = session.gitBranch ? escapeHtml(session.gitBranch) : null;
|
|
3510
|
-
const createdDisplay = session.createdAt ? formatDate(session.createdAt) : '';
|
|
3511
|
-
const modifiedDisplay = formatDate(session.modifiedAt);
|
|
3512
|
-
const timeDisplay =
|
|
3513
|
-
session.createdAt && createdDisplay !== modifiedDisplay
|
|
3514
|
-
? `Created ${createdDisplay} · Modified ${modifiedDisplay}`
|
|
3515
|
-
: modifiedDisplay;
|
|
3516
|
-
const tooltip = [session.id, session.project, timeDisplay, gitBranch ? `Branch: ${gitBranch}` : '']
|
|
3517
|
-
.filter(Boolean)
|
|
3518
|
-
.join(' | ');
|
|
3519
3592
|
const isTeam = session.isTeam;
|
|
3520
3593
|
const memberCount = session.memberCount || 0;
|
|
3521
3594
|
|
|
@@ -3539,14 +3612,20 @@ function renderSessions() {
|
|
|
3539
3612
|
: '';
|
|
3540
3613
|
const justFinishedClass = isJustFinished(session) ? 'just-finished' : '';
|
|
3541
3614
|
const sid = escAttrJs(session.id);
|
|
3615
|
+
const progressHtml =
|
|
3616
|
+
total > 0
|
|
3617
|
+
? `<div class="session-progress" title="${escapeHtml(session.completed)} of ${total} tasks done"><div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div><span class="progress-text">${session.completed}/${total}</span></div>`
|
|
3618
|
+
: '';
|
|
3619
|
+
const metricsHtml = progressHtml + (showCtx ? renderContextBar(session) : '');
|
|
3542
3620
|
return `
|
|
3543
|
-
<button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${session.unread ? 'unread' : ''} ${tempClass} ${idleClass} ${justFinishedClass}
|
|
3621
|
+
<button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${session.unread ? 'unread' : ''} ${tempClass} ${idleClass} ${justFinishedClass}">
|
|
3544
3622
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
3545
3623
|
<div class="session-name">${escapeHtml(sessionName)}</div>
|
|
3546
3624
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
3547
3625
|
${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
|
|
3548
|
-
${
|
|
3549
|
-
<div class="session-
|
|
3626
|
+
${metricsHtml ? `<div class="session-metrics">${metricsHtml}</div>` : ''}
|
|
3627
|
+
<div class="session-footer">
|
|
3628
|
+
<div class="session-time">${formatDate(session.modifiedAt)}</div>
|
|
3550
3629
|
<span class="session-indicators">
|
|
3551
3630
|
${isTeam ? `<span class="team-badge" title="${memberCount} team members"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${memberCount}</span>` : ''}
|
|
3552
3631
|
${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${sid}')" title="View session info"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></span>` : ''}
|
|
@@ -3558,16 +3637,13 @@ function renderSessions() {
|
|
|
3558
3637
|
${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
|
|
3559
3638
|
${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escAttrJs(session.planSourceSessionId)}')">${ICON_PLAN}</span>` : ''}
|
|
3560
3639
|
${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
|
|
3640
|
+
${session.dispatched ? `<span class="dispatched-badge"${session.dispatched.parent ? ` onclick="event.stopPropagation(); revealSession('${escAttrJs(session.dispatched.parent)}')"` : ''} title="${escapeHtml(dispatchedTitle(session.dispatched))}">${ICON_DISPATCHED}</span>` : ''}
|
|
3561
3641
|
${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>` : ''}
|
|
3562
3642
|
${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
|
|
3563
3643
|
${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
|
|
3564
3644
|
${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
|
|
3565
3645
|
</span>
|
|
3566
|
-
<div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div>
|
|
3567
|
-
<span class="progress-text">${session.completed}/${total}</span>
|
|
3568
3646
|
</div>
|
|
3569
|
-
${showCtx ? renderContextBar(session.contextStatus) : ''}
|
|
3570
|
-
<div class="session-time">${formatDate(session.modifiedAt)}</div>
|
|
3571
3647
|
</button>
|
|
3572
3648
|
`;
|
|
3573
3649
|
};
|
|
@@ -3577,7 +3653,7 @@ function renderSessions() {
|
|
|
3577
3653
|
if (zenMode) {
|
|
3578
3654
|
sessionsList.innerHTML = `${renderSessionCard(zenSession)}
|
|
3579
3655
|
<div class="zen-panel">
|
|
3580
|
-
${renderContextDetail(zenSession
|
|
3656
|
+
${renderContextDetail(zenSession, { tokens: false }) || '<div class="zen-panel-empty">No context data for this session</div>'}
|
|
3581
3657
|
${renderScratchpadRow(zenSession)}
|
|
3582
3658
|
${renderLinkedDocsHtml(zenSession.id)}
|
|
3583
3659
|
${renderArtifactsHtml(zenSession.id)}
|
|
@@ -3591,7 +3667,7 @@ function renderSessions() {
|
|
|
3591
3667
|
}
|
|
3592
3668
|
|
|
3593
3669
|
const groupPinned = store.getItem('groupPinnedSessions') !== 'false';
|
|
3594
|
-
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 :
|
|
3670
|
+
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
|
|
3595
3671
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
3596
3672
|
const countHtml = (arr) => {
|
|
3597
3673
|
const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
@@ -3609,12 +3685,10 @@ function renderSessions() {
|
|
|
3609
3685
|
|
|
3610
3686
|
const renderGroupSessions = (sessions, pinKey) => {
|
|
3611
3687
|
if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
|
|
3612
|
-
const
|
|
3613
|
-
if (
|
|
3614
|
-
const
|
|
3615
|
-
const gUnpinned = sessions.filter((s) => !isPlacedPinned(s.id) || isSessionActive(s) || isPlacedSticky(s.id));
|
|
3688
|
+
const gIdlePinned = sessions.filter(isInPinnedGroup);
|
|
3689
|
+
if (gIdlePinned.length === 0) return sessions.map(renderSessionCard).join('');
|
|
3690
|
+
const gUnpinned = sessions.filter((s) => !isInPinnedGroup(s));
|
|
3616
3691
|
const pinCollapsed = collapsedProjectGroups.has(pinKey);
|
|
3617
|
-
if (gIdlePinned.length === 0) return gUnpinned.map(renderSessionCard).join('');
|
|
3618
3692
|
return (
|
|
3619
3693
|
'<div class="pinned-sub-section">' +
|
|
3620
3694
|
'<div class="pinned-sub-header' +
|
|
@@ -3782,7 +3856,7 @@ function renderSessions() {
|
|
|
3782
3856
|
ungrouped.sort(pinSort);
|
|
3783
3857
|
}
|
|
3784
3858
|
|
|
3785
|
-
// Stable group order: preserve existing order,
|
|
3859
|
+
// Stable group order: preserve existing order, put new groups first, sorted by recency.
|
|
3786
3860
|
// Grouped projects stay in the array (they are just not rendered here), so dragging one
|
|
3787
3861
|
// back out restores its old slot instead of jumping to the top.
|
|
3788
3862
|
const latestByPath = new Map();
|
|
@@ -3791,11 +3865,9 @@ function renderSessions() {
|
|
|
3791
3865
|
const t = new Date(s.modifiedAt).getTime();
|
|
3792
3866
|
latestByPath.set(s.project, Math.max(latestByPath.get(s.project) ?? -Infinity, t));
|
|
3793
3867
|
}
|
|
3794
|
-
const
|
|
3795
|
-
const
|
|
3796
|
-
|
|
3797
|
-
if (newPaths.length > 1) newPaths.sort((a, b) => latestByPath.get(b) - latestByPath.get(a));
|
|
3798
|
-
stableGroupOrder = [...keptOrder, ...newPaths];
|
|
3868
|
+
const byRecency = [...latestByPath.keys()].sort((a, b) => latestByPath.get(b) - latestByPath.get(a));
|
|
3869
|
+
const loadedPaths = new Set(sessions.map((s) => s.project));
|
|
3870
|
+
stableGroupOrder = mergeOrder(PROJECT_ORDER_KEY, stableGroupOrder, loadedPaths, byRecency);
|
|
3799
3871
|
const sortedGroups = stableGroupOrder.filter((p) => groups.has(p)).map((p) => [p, groups.get(p)]);
|
|
3800
3872
|
|
|
3801
3873
|
// The section header is rendered even when nothing is left under it: it is the drop target
|
|
@@ -3936,11 +4008,13 @@ function renderProjectView() {
|
|
|
3936
4008
|
}
|
|
3937
4009
|
|
|
3938
4010
|
// Claude Code keeps a blocker's id in blockedBy after the blocker completes.
|
|
3939
|
-
function
|
|
4011
|
+
function findSiblingTask(task, id) {
|
|
3940
4012
|
const list = (t) => t.sessionId || t._taskDir;
|
|
3941
|
-
return (
|
|
3942
|
-
|
|
3943
|
-
|
|
4013
|
+
return currentTasks.find((t) => t.id === id && list(t) === list(task));
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
function openBlockers(task) {
|
|
4017
|
+
return (task.blockedBy || []).filter((id) => findSiblingTask(task, id)?.status !== 'completed');
|
|
3944
4018
|
}
|
|
3945
4019
|
|
|
3946
4020
|
function renderTaskCard(task) {
|
|
@@ -3965,7 +4039,7 @@ function renderTaskCard(task) {
|
|
|
3965
4039
|
aria-label="${escapeHtml(task.subject)} — ${task.status.replace('_', ' ')}">
|
|
3966
4040
|
<div class="task-id">
|
|
3967
4041
|
<span>#${taskId}</span>
|
|
3968
|
-
${isBlocked ?
|
|
4042
|
+
${isBlocked ? `<span class="task-blocked" title="Blocked">waiting on ${blockers.map((id) => `#${escapeHtml(id)}`).join(' ')}</span>` : ''}
|
|
3969
4043
|
${
|
|
3970
4044
|
task.owner
|
|
3971
4045
|
? (
|
|
@@ -3979,7 +4053,6 @@ function renderTaskCard(task) {
|
|
|
3979
4053
|
</div>
|
|
3980
4054
|
<div class="task-title">${escapeHtml(task.subject)}</div>
|
|
3981
4055
|
${task.status === 'in_progress' && task.activeForm ? `<div class="task-active">${escapeHtml(task.activeForm)}</div>` : ''}
|
|
3982
|
-
${isBlocked ? `<div class="task-blocked">Waiting on ${blockers.map((id) => `#${id}`).join(', ')}</div>` : ''}
|
|
3983
4056
|
${task.description ? `<div class="task-desc">${escapeHtml(task.description.split('\n')[0])}</div>` : ''}
|
|
3984
4057
|
</div>
|
|
3985
4058
|
`;
|
|
@@ -4351,6 +4424,12 @@ function sgTransientGroup(name) {
|
|
|
4351
4424
|
return group;
|
|
4352
4425
|
}
|
|
4353
4426
|
|
|
4427
|
+
function dispatchedTitle({ parent, outcome }) {
|
|
4428
|
+
const starter = parent && sessions.find((s) => s.id === parent);
|
|
4429
|
+
const by = starter ? sessionDisplayName(starter) : 'another session';
|
|
4430
|
+
return `Started by ${by}${outcome ? ` · ${outcome}` : ''}${parent ? ' — click to reveal it' : ''}`;
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4354
4433
|
// A user group with the same name takes the session in, so Keep does not split a group in two.
|
|
4355
4434
|
// With no group of its own, a started session follows its starter into the starter's named group.
|
|
4356
4435
|
function sgDispatchGroupFor(session) {
|
|
@@ -4534,19 +4613,70 @@ function sgCommitRename(input, save) {
|
|
|
4534
4613
|
//#region SESSION_GROUPS_DND
|
|
4535
4614
|
// At most one zone is lit, so the reference is enough - dragover fires many times a second.
|
|
4536
4615
|
let sgLitZone = null;
|
|
4616
|
+
let sgDragEl = null;
|
|
4617
|
+
const SG_LIT_CLASSES = ['sg-drop-over', 'sg-insert-before', 'sg-insert-after'];
|
|
4618
|
+
const SG_LIT_SELECTOR = SG_LIT_CLASSES.map((c) => `.${c}`).join(', ');
|
|
4537
4619
|
|
|
4538
4620
|
function sgClearDropTargets() {
|
|
4539
|
-
sgLitZone?.classList.remove(
|
|
4621
|
+
sgLitZone?.classList.remove(...SG_LIT_CLASSES);
|
|
4540
4622
|
sgLitZone = null;
|
|
4541
4623
|
}
|
|
4542
4624
|
|
|
4543
|
-
function sgLightDropTarget(zone) {
|
|
4544
|
-
if (sgLitZone === zone) return;
|
|
4625
|
+
function sgLightDropTarget(zone, cls = 'sg-drop-over') {
|
|
4626
|
+
if (sgLitZone === zone && zone.classList.contains(cls)) return;
|
|
4545
4627
|
sgClearDropTargets();
|
|
4546
|
-
zone.classList.add(
|
|
4628
|
+
zone.classList.add(cls);
|
|
4547
4629
|
sgLitZone = zone;
|
|
4548
4630
|
}
|
|
4549
4631
|
|
|
4632
|
+
// A session dropped on the edge of a card in its own list moves next to it. `share` is the part
|
|
4633
|
+
// of the card's height on each edge that means insert; the middle keeps its group gesture.
|
|
4634
|
+
function sgInsertHit(card, e, share) {
|
|
4635
|
+
const rect = card.getBoundingClientRect();
|
|
4636
|
+
const y = e.clientY - rect.top;
|
|
4637
|
+
const after = y > rect.height * (1 - share);
|
|
4638
|
+
if (!after && y >= rect.height * share) return null;
|
|
4639
|
+
return { zone: card, cls: after ? 'sg-insert-after' : 'sg-insert-before', insert: { card, after } };
|
|
4640
|
+
}
|
|
4641
|
+
|
|
4642
|
+
function sgSiblingCard(target) {
|
|
4643
|
+
if (sgDrag?.kind !== 'session' || sessionFilter !== 'active' || !sgDragEl) return null;
|
|
4644
|
+
const card = target.closest('.session-item');
|
|
4645
|
+
return card && card !== sgDragEl && card.parentElement === sgDragEl.parentElement ? card : null;
|
|
4646
|
+
}
|
|
4647
|
+
|
|
4648
|
+
function sgDropZone(target, e) {
|
|
4649
|
+
const sibling = sgSiblingCard(target);
|
|
4650
|
+
const hit = sgGroupDropZone(target);
|
|
4651
|
+
// Where the middle of a card does nothing, the whole card is an insert target.
|
|
4652
|
+
return (sibling && sgInsertHit(sibling, e, hit ? 0.25 : 0.5)) || hit;
|
|
4653
|
+
}
|
|
4654
|
+
|
|
4655
|
+
function sgInsertSession(dragId, { card, after }) {
|
|
4656
|
+
const ref = card.dataset.sessionId;
|
|
4657
|
+
const group = card.closest('.project-group-sessions') ? null : sgGroupOf('session', ref);
|
|
4658
|
+
if (group && sgGroupOf('session', dragId)?.id === group.id) {
|
|
4659
|
+
// Loose sessions in a named group follow the group's member order, not the sidebar order.
|
|
4660
|
+
const members = insertNear(
|
|
4661
|
+
group.members.filter((m) => !(m.type === 'session' && m.ref === dragId)),
|
|
4662
|
+
(m) => m.type === 'session' && m.ref === ref,
|
|
4663
|
+
{ type: 'session', ref: dragId },
|
|
4664
|
+
after,
|
|
4665
|
+
);
|
|
4666
|
+
if (!members) return;
|
|
4667
|
+
group.members = members;
|
|
4668
|
+
persistSessionGroups();
|
|
4669
|
+
return;
|
|
4670
|
+
}
|
|
4671
|
+
const order = insertNear(
|
|
4672
|
+
sessionOrder.filter((id) => id !== dragId),
|
|
4673
|
+
(id) => id === ref,
|
|
4674
|
+
dragId,
|
|
4675
|
+
after,
|
|
4676
|
+
);
|
|
4677
|
+
if (order) sessionOrder = saveOrder(SESSION_ORDER_KEY, sessionOrder, order);
|
|
4678
|
+
}
|
|
4679
|
+
|
|
4550
4680
|
function sgDragPayload(el) {
|
|
4551
4681
|
if (el.classList.contains('session-group-header')) return { kind: 'group', ref: el.dataset.groupId };
|
|
4552
4682
|
// data-project-path is on real project headers only, so the sentinels drop out by themselves.
|
|
@@ -4560,7 +4690,7 @@ function sgDragPayload(el) {
|
|
|
4560
4690
|
// Resolve the element under the pointer to a legal drop zone for the in-flight drag.
|
|
4561
4691
|
// Returns {zone} to ungroup, {zone, groupId} to assign, or {zone, pairWith}/{zone, pairSession}
|
|
4562
4692
|
// to stack two ungrouped items into a brand-new group.
|
|
4563
|
-
function
|
|
4693
|
+
function sgGroupDropZone(target) {
|
|
4564
4694
|
if (!sgDrag) return null;
|
|
4565
4695
|
const base =
|
|
4566
4696
|
'.sg-ungroup-zone, .session-group-header, .session-group-sessions, .project-group-header, .project-group-sessions';
|
|
@@ -4644,6 +4774,7 @@ function sgOnDragStart(e) {
|
|
|
4644
4774
|
return;
|
|
4645
4775
|
}
|
|
4646
4776
|
sgDrag = payload;
|
|
4777
|
+
sgDragEl = el;
|
|
4647
4778
|
el.classList.add('sg-dragging');
|
|
4648
4779
|
sessionsList.classList.add('sg-dragging-active');
|
|
4649
4780
|
e.dataTransfer.effectAllowed = 'move';
|
|
@@ -4653,7 +4784,7 @@ function sgOnDragStart(e) {
|
|
|
4653
4784
|
}
|
|
4654
4785
|
|
|
4655
4786
|
function sgOnDragOver(e) {
|
|
4656
|
-
const hit = sgDropZone(e.target);
|
|
4787
|
+
const hit = sgDropZone(e.target, e);
|
|
4657
4788
|
// Moving onto a spot that refuses the drop has to put the previous highlight out, or two zones
|
|
4658
4789
|
// stay lit and the one under the pointer is not the one that would receive the drop.
|
|
4659
4790
|
if (!hit) {
|
|
@@ -4662,15 +4793,20 @@ function sgOnDragOver(e) {
|
|
|
4662
4793
|
}
|
|
4663
4794
|
e.preventDefault();
|
|
4664
4795
|
e.dataTransfer.dropEffect = 'move';
|
|
4665
|
-
sgLightDropTarget(hit.zone);
|
|
4796
|
+
sgLightDropTarget(hit.zone, hit.cls);
|
|
4666
4797
|
}
|
|
4667
4798
|
|
|
4668
4799
|
function sgOnDrop(e) {
|
|
4669
|
-
const hit = sgDropZone(e.target);
|
|
4800
|
+
const hit = sgDropZone(e.target, e);
|
|
4670
4801
|
if (!hit) return;
|
|
4671
4802
|
e.preventDefault();
|
|
4672
4803
|
e.stopPropagation();
|
|
4673
4804
|
const drag = sgDrag;
|
|
4805
|
+
if (hit.insert) {
|
|
4806
|
+
sgInsertSession(drag.ref, hit.insert);
|
|
4807
|
+
sgFinishDrag();
|
|
4808
|
+
return;
|
|
4809
|
+
}
|
|
4674
4810
|
if (hit.pairWith || hit.pairSession) {
|
|
4675
4811
|
// Stacked onto an ungrouped item: seed a group, then let the user name it in place.
|
|
4676
4812
|
const target = hit.pairWith
|
|
@@ -4701,6 +4837,7 @@ function sgOnDrop(e) {
|
|
|
4701
4837
|
|
|
4702
4838
|
function sgFinishDrag() {
|
|
4703
4839
|
sgDrag = null;
|
|
4840
|
+
sgDragEl = null;
|
|
4704
4841
|
sgClearDropTargets();
|
|
4705
4842
|
sessionsList.classList.remove('sg-dragging-active');
|
|
4706
4843
|
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
@@ -4721,7 +4858,7 @@ function initSessionGroupsDnd() {
|
|
|
4721
4858
|
sessionsList.addEventListener('dragstart', sgOnDragStart);
|
|
4722
4859
|
sessionsList.addEventListener('dragover', sgOnDragOver);
|
|
4723
4860
|
sessionsList.addEventListener('dragleave', (e) => {
|
|
4724
|
-
const zone = e.target.closest?.(
|
|
4861
|
+
const zone = e.target.closest?.(SG_LIT_SELECTOR);
|
|
4725
4862
|
if (zone && !zone.contains(e.relatedTarget)) sgClearDropTargets();
|
|
4726
4863
|
});
|
|
4727
4864
|
sessionsList.addEventListener('drop', sgOnDrop);
|
|
@@ -5131,7 +5268,7 @@ function uncollapseFor(session) {
|
|
|
5131
5268
|
}
|
|
5132
5269
|
|
|
5133
5270
|
function pinnedCollapseKeys(session) {
|
|
5134
|
-
if (!
|
|
5271
|
+
if (!isInPinnedGroup(session)) return [];
|
|
5135
5272
|
const group = sgGroupForSession(session);
|
|
5136
5273
|
const host = sgHostOf(group, session);
|
|
5137
5274
|
return [pinKey(session.project || '__ungrouped__'), host && pinKey(host), group && `__pinned_group_${group.id}__`];
|
|
@@ -5338,22 +5475,33 @@ async function showTaskDetail(taskId, sessionId = null) {
|
|
|
5338
5475
|
const blockers = openBlockers(task);
|
|
5339
5476
|
const isBlocked = blockers.length > 0;
|
|
5340
5477
|
const actualSessionId = task.sessionId || sessionId || currentSessionId;
|
|
5341
|
-
|
|
5478
|
+
const isProjectView = viewMode === 'project';
|
|
5479
|
+
const pencil = isProjectView ? '' : DETAIL_EDIT_SVG;
|
|
5480
|
+
const editable = isProjectView ? '' : ' editable';
|
|
5481
|
+
const depChip = (id, kind) => {
|
|
5482
|
+
const dep = findSiblingTask(task, id);
|
|
5483
|
+
const label = dep ? `#${id} ${dep.subject}` : `#${id}`;
|
|
5484
|
+
return `<button type="button" class="detail-dep ${escapeHtml(kind)}" data-dep-id="${escapeHtml(id)}" title="${escapeHtml(label)}"><span class="dot"></span><span class="detail-dep-text">${escapeHtml(label)}</span></button>`;
|
|
5485
|
+
};
|
|
5486
|
+
const deps = [
|
|
5487
|
+
isBlocked ? ['wait', 'Waiting on', blockers] : null,
|
|
5488
|
+
task.blocks?.length ? ['next', 'Unblocks', task.blocks] : null,
|
|
5489
|
+
].filter(Boolean);
|
|
5490
|
+
detailContent.classList.toggle('blocked', isBlocked && task.status === 'pending');
|
|
5342
5491
|
detailContent.innerHTML = `
|
|
5343
5492
|
<div class="detail-section">
|
|
5344
|
-
<div class="detail-label">Task #${task.id}</div>
|
|
5345
|
-
<h2 class="detail-title">${escapeHtml(task.subject)}</h2>
|
|
5493
|
+
<div class="detail-label">Task #${task.id}${pencil}</div>
|
|
5494
|
+
<h2 class="detail-title${escapeHtml(editable)}">${escapeHtml(task.subject)}</h2>
|
|
5346
5495
|
</div>
|
|
5347
5496
|
|
|
5348
5497
|
<div class="detail-section" style="display: flex; gap: 12px; align-items: center;">
|
|
5349
5498
|
<div>${statusLabels[task.status] || ''}</div>
|
|
5350
5499
|
${task.owner ? `<div style="font-size: 13px; color: ${getOwnerColor(task.owner).color}; font-weight: 500;">${escapeHtml(task.owner)}</div>` : ''}
|
|
5351
|
-
${isBlocked && task.status !== 'in_progress' ? '<div style="font-size: 10px; color: var(--warning);">Blocked</div>' : ''}
|
|
5352
5500
|
</div>
|
|
5353
5501
|
|
|
5354
5502
|
<div class="detail-section">
|
|
5355
|
-
<div class="detail-label">Description</div>
|
|
5356
|
-
<div class="detail-desc">${task.description ? renderMarkdown(task.description) : '<em style="color: var(--text-muted);">No description</em>'}</div>
|
|
5503
|
+
<div class="detail-label">Description${pencil}</div>
|
|
5504
|
+
<div class="detail-desc${escapeHtml(editable)}">${task.description ? renderMarkdown(task.description) : '<em style="color: var(--text-muted);">No description</em>'}</div>
|
|
5357
5505
|
</div>
|
|
5358
5506
|
|
|
5359
5507
|
${
|
|
@@ -5369,33 +5517,24 @@ async function showTaskDetail(taskId, sessionId = null) {
|
|
|
5369
5517
|
}
|
|
5370
5518
|
|
|
5371
5519
|
${
|
|
5372
|
-
|
|
5520
|
+
deps.length
|
|
5373
5521
|
? `
|
|
5374
5522
|
<div class="detail-section">
|
|
5375
|
-
<div class="detail-label">
|
|
5523
|
+
<div class="detail-label">Dependencies</div>
|
|
5376
5524
|
<div class="detail-deps">
|
|
5377
|
-
|
|
5378
|
-
</div>
|
|
5379
|
-
</div>`
|
|
5380
|
-
: ''
|
|
5381
|
-
}
|
|
5382
|
-
|
|
5383
|
-
${
|
|
5384
|
-
task.blocks && task.blocks.length > 0
|
|
5385
|
-
? `
|
|
5386
|
-
<div class="detail-section">
|
|
5387
|
-
<div class="detail-label">Blocks</div>
|
|
5388
|
-
<div class="detail-deps">
|
|
5389
|
-
<div class="detail-box blocks"><strong>Blocks:</strong> ${task.blocks.map((id) => `#${id}`).join(', ')}</div>
|
|
5525
|
+
${deps.map(([kind, label, ids]) => `<span class="detail-deps-label ${escapeHtml(kind)}">${label}</span><div class="detail-deps-list">${ids.map((id) => depChip(id, kind)).join('')}</div>`).join('')}
|
|
5390
5526
|
</div>
|
|
5391
5527
|
</div>`
|
|
5392
5528
|
: ''
|
|
5393
5529
|
}
|
|
5394
5530
|
`;
|
|
5395
5531
|
|
|
5532
|
+
for (const chip of detailContent.querySelectorAll('.detail-dep')) {
|
|
5533
|
+
chip.onclick = () => showTaskDetail(chip.dataset.depId, actualSessionId);
|
|
5534
|
+
}
|
|
5535
|
+
|
|
5396
5536
|
// Setup button handlers (read-only in project view)
|
|
5397
5537
|
const deleteBtn = document.getElementById('delete-task-btn');
|
|
5398
|
-
const isProjectView = viewMode === 'project';
|
|
5399
5538
|
deleteBtn.style.display = isProjectView ? 'none' : '';
|
|
5400
5539
|
if (!isProjectView) deleteBtn.onclick = () => deleteTask(task.id, actualSessionId);
|
|
5401
5540
|
|
|
@@ -5428,7 +5567,7 @@ function editTitle(titleEl, task, sessionId) {
|
|
|
5428
5567
|
if (val && val !== task.subject) {
|
|
5429
5568
|
await saveTaskField(task.id, sessionId, 'subject', val);
|
|
5430
5569
|
} else {
|
|
5431
|
-
showTaskDetail(task.id, sessionId);
|
|
5570
|
+
await showTaskDetail(task.id, sessionId);
|
|
5432
5571
|
}
|
|
5433
5572
|
};
|
|
5434
5573
|
|
|
@@ -5438,6 +5577,18 @@ function editTitle(titleEl, task, sessionId) {
|
|
|
5438
5577
|
save();
|
|
5439
5578
|
}
|
|
5440
5579
|
if (e.key === 'Escape') showTaskDetail(task.id, sessionId);
|
|
5580
|
+
// Saving re-renders the panel and drops the input, so native Tab would land on <body>.
|
|
5581
|
+
if (e.key === 'Tab') {
|
|
5582
|
+
e.preventDefault();
|
|
5583
|
+
input.onblur = null;
|
|
5584
|
+
const shift = e.shiftKey;
|
|
5585
|
+
save().then(() => {
|
|
5586
|
+
const target = shift
|
|
5587
|
+
? document.getElementById('delete-task-btn')
|
|
5588
|
+
: detailContent.querySelector('.detail-dep') || document.getElementById('close-detail');
|
|
5589
|
+
target?.focus();
|
|
5590
|
+
});
|
|
5591
|
+
}
|
|
5441
5592
|
};
|
|
5442
5593
|
input.onblur = () => save();
|
|
5443
5594
|
}
|
|
@@ -5477,8 +5628,10 @@ function editDescription(descEl, task, sessionId) {
|
|
|
5477
5628
|
|
|
5478
5629
|
saveBtn.onclick = save;
|
|
5479
5630
|
cancelBtn.onclick = () => showTaskDetail(task.id, sessionId);
|
|
5480
|
-
|
|
5481
|
-
if (e.key
|
|
5631
|
+
wrapper.onkeydown = (e) => {
|
|
5632
|
+
if (e.key !== 'Escape') return;
|
|
5633
|
+
e.stopPropagation();
|
|
5634
|
+
showTaskDetail(task.id, sessionId);
|
|
5482
5635
|
};
|
|
5483
5636
|
}
|
|
5484
5637
|
|
|
@@ -5493,7 +5646,7 @@ async function saveTaskField(taskId, sessionId, field, value) {
|
|
|
5493
5646
|
if (res.ok) {
|
|
5494
5647
|
lastCurrentTasksHash = null;
|
|
5495
5648
|
await fetchTasks(sessionId);
|
|
5496
|
-
showTaskDetail(taskId, sessionId);
|
|
5649
|
+
await showTaskDetail(taskId, sessionId);
|
|
5497
5650
|
}
|
|
5498
5651
|
} catch (error) {
|
|
5499
5652
|
console.error('Failed to update task:', error);
|
|
@@ -5507,10 +5660,6 @@ function closeDetailPanel() {
|
|
|
5507
5660
|
taskHighlightDimmed = false;
|
|
5508
5661
|
}
|
|
5509
5662
|
|
|
5510
|
-
let deleteTaskId = null;
|
|
5511
|
-
let deleteSessionId = null;
|
|
5512
|
-
let deleteModalKeyHandler = null;
|
|
5513
|
-
|
|
5514
5663
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5515
5664
|
function showBlockedTaskModal(task) {
|
|
5516
5665
|
const messageDiv = document.getElementById('blocked-task-message');
|
|
@@ -5555,62 +5704,47 @@ function closeBlockedTaskModal() {
|
|
|
5555
5704
|
//#endregion
|
|
5556
5705
|
|
|
5557
5706
|
//#region DELETE_TASK
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
const
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
const modal = document.getElementById('delete-confirm-modal');
|
|
5707
|
+
// Set while #confirm-modal is open; settles the open confirmModal() promise.
|
|
5708
|
+
let closeConfirmModal = null;
|
|
5709
|
+
|
|
5710
|
+
function confirmModal({ title, message, okLabel }) {
|
|
5711
|
+
const modal = document.getElementById('confirm-modal');
|
|
5712
|
+
document.getElementById('confirm-modal-title').textContent = title;
|
|
5713
|
+
document.getElementById('confirm-modal-message').textContent = message;
|
|
5714
|
+
const buttons = [document.getElementById('confirm-cancel-btn'), document.getElementById('confirm-ok-btn')];
|
|
5715
|
+
buttons[1].textContent = okLabel;
|
|
5569
5716
|
modal.classList.add('visible');
|
|
5717
|
+
buttons[1].focus();
|
|
5570
5718
|
|
|
5571
|
-
const
|
|
5572
|
-
|
|
5573
|
-
buttons[focusIdx].focus();
|
|
5574
|
-
|
|
5575
|
-
deleteModalKeyHandler = (e) => {
|
|
5576
|
-
if (e.key === 'Escape') {
|
|
5577
|
-
e.preventDefault();
|
|
5578
|
-
closeDeleteConfirmModal();
|
|
5579
|
-
} else if (matchKey(e, 'ArrowLeft')) {
|
|
5719
|
+
const onKey = (e) => {
|
|
5720
|
+
if (matchKey(e, 'ArrowLeft', 'ArrowRight')) {
|
|
5580
5721
|
e.preventDefault();
|
|
5581
|
-
|
|
5582
|
-
buttons[focusIdx].focus();
|
|
5583
|
-
} else if (matchKey(e, 'ArrowRight')) {
|
|
5584
|
-
e.preventDefault();
|
|
5585
|
-
focusIdx = 1;
|
|
5586
|
-
buttons[focusIdx].focus();
|
|
5722
|
+
buttons[e.key === 'ArrowLeft' ? 0 : 1].focus();
|
|
5587
5723
|
} else if (e.key === 'Enter') {
|
|
5588
5724
|
e.preventDefault();
|
|
5589
|
-
buttons[
|
|
5725
|
+
(document.activeElement === buttons[0] ? buttons[0] : buttons[1]).click();
|
|
5590
5726
|
}
|
|
5591
5727
|
};
|
|
5592
|
-
document.addEventListener('keydown',
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
document.removeEventListener('keydown', deleteModalKeyHandler);
|
|
5602
|
-
deleteModalKeyHandler = null;
|
|
5603
|
-
}
|
|
5728
|
+
document.addEventListener('keydown', onKey);
|
|
5729
|
+
return new Promise((resolve) => {
|
|
5730
|
+
closeConfirmModal = (ok) => {
|
|
5731
|
+
modal.classList.remove('visible');
|
|
5732
|
+
document.removeEventListener('keydown', onKey);
|
|
5733
|
+
closeConfirmModal = null;
|
|
5734
|
+
resolve(ok);
|
|
5735
|
+
};
|
|
5736
|
+
});
|
|
5604
5737
|
}
|
|
5605
5738
|
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
if (!
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5739
|
+
async function deleteTask(taskId, sessionId) {
|
|
5740
|
+
const task = currentTasks.find((t) => t.id === taskId);
|
|
5741
|
+
if (!task) return;
|
|
5742
|
+
const ok = await confirmModal({
|
|
5743
|
+
title: 'Delete Task',
|
|
5744
|
+
message: `Delete task "${task.subject}"? This cannot be undone.`,
|
|
5745
|
+
okLabel: 'Delete',
|
|
5746
|
+
});
|
|
5747
|
+
if (!ok) return;
|
|
5614
5748
|
|
|
5615
5749
|
try {
|
|
5616
5750
|
const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
|
|
@@ -5696,7 +5830,7 @@ const SHORTCUT_TABS = [
|
|
|
5696
5830
|
{ keys: ['.'], label: 'Pin / unpin' },
|
|
5697
5831
|
{ keys: ['>'], label: 'Toggle sticky' },
|
|
5698
5832
|
{ keys: ['Shift', 'F10'], combo: true, label: 'Move to group (or Menu key)' },
|
|
5699
|
-
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session' },
|
|
5833
|
+
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
|
|
5700
5834
|
{ keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
5701
5835
|
{ keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
|
|
5702
5836
|
{ keys: ['↑', '↓'], label: 'Previous / next message in detail' },
|
|
@@ -6177,8 +6311,7 @@ async function _storageViewSession(id) {
|
|
|
6177
6311
|
}
|
|
6178
6312
|
|
|
6179
6313
|
function _storageUnpinSession(id) {
|
|
6180
|
-
|
|
6181
|
-
stickySessionIds.delete(id);
|
|
6314
|
+
clearSessionPin(id);
|
|
6182
6315
|
savePinnedSessions();
|
|
6183
6316
|
renderSessions();
|
|
6184
6317
|
_renderStorageTab();
|
|
@@ -6463,6 +6596,69 @@ function matchKey(e, ...keys) {
|
|
|
6463
6596
|
return keys.some((k) => e.key === k || e.code === k);
|
|
6464
6597
|
}
|
|
6465
6598
|
|
|
6599
|
+
function dismissSessionFromList(sid) {
|
|
6600
|
+
const prevIdx = selectedSessionIdx;
|
|
6601
|
+
if (!setSessionDismissed(sid, true)) return;
|
|
6602
|
+
const newItems = getNavigableItems();
|
|
6603
|
+
if (isAnyPinned(sid)) {
|
|
6604
|
+
const pinnedIdx = newItems.findIndex((el) => el.dataset?.sessionId === sid);
|
|
6605
|
+
if (pinnedIdx >= 0) selectSessionByIndex(pinnedIdx, newItems);
|
|
6606
|
+
return;
|
|
6607
|
+
}
|
|
6608
|
+
// Ctrl+Alt+S would reveal the dismissed session, and revealing restores it.
|
|
6609
|
+
const forget = (x) => (x === sid ? null : x);
|
|
6610
|
+
const fallback = currentSessionId === sid && lastSessionId === sid ? previousSessionId : null;
|
|
6611
|
+
setSwapPair(forget(lastSessionId), forget(previousSessionId));
|
|
6612
|
+
const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
|
|
6613
|
+
// If the dismissed session is currently open, navigate to the previous one
|
|
6614
|
+
if (currentSessionId === sid || selectedSessionId === sid) {
|
|
6615
|
+
selectedSessionId = null;
|
|
6616
|
+
if (fallback && !dismissedSessionIds.has(fallback) && sessions.some((s) => s.id === fallback)) {
|
|
6617
|
+
revealSession(fallback).then(() => {
|
|
6618
|
+
const items = getNavigableItems();
|
|
6619
|
+
const idx = items.findIndex((el) => el.dataset?.sessionId === fallback);
|
|
6620
|
+
if (idx >= 0) selectSessionByIndex(idx, items);
|
|
6621
|
+
});
|
|
6622
|
+
} else if (targetIdx >= 0) {
|
|
6623
|
+
const targetSid = newItems[targetIdx]?.dataset?.sessionId;
|
|
6624
|
+
if (targetSid) {
|
|
6625
|
+
fetchTasks(targetSid).then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
|
|
6626
|
+
} else {
|
|
6627
|
+
showNoSession();
|
|
6628
|
+
selectSessionByIndex(targetIdx, getNavigableItems());
|
|
6629
|
+
}
|
|
6630
|
+
} else {
|
|
6631
|
+
showNoSession();
|
|
6632
|
+
}
|
|
6633
|
+
} else if (targetIdx >= 0) {
|
|
6634
|
+
selectSessionByIndex(targetIdx, newItems);
|
|
6635
|
+
}
|
|
6636
|
+
}
|
|
6637
|
+
|
|
6638
|
+
async function confirmDismissBlockers(sid) {
|
|
6639
|
+
const sticky = stickySessionIds.has(sid);
|
|
6640
|
+
const terminal = runningTerminals.has(sid);
|
|
6641
|
+
if (!sticky && !terminal) return true;
|
|
6642
|
+
const session = sessions.find((s) => s.id === sid);
|
|
6643
|
+
const name = session ? sessionDisplayName(session) : sid.slice(0, 8);
|
|
6644
|
+
const has = [sticky && 'a sticky pin', terminal && 'a running terminal'].filter(Boolean).join(' and ');
|
|
6645
|
+
const [title, verb, okLabel] =
|
|
6646
|
+
sticky && terminal
|
|
6647
|
+
? ['Unpin and Close Terminal', 'Unpin, close the terminal', 'Unpin, close and dismiss']
|
|
6648
|
+
: sticky
|
|
6649
|
+
? ['Remove Sticky Pin', 'Remove the pin', 'Unpin and dismiss']
|
|
6650
|
+
: ['Close Terminal', 'Close the terminal', 'Close and dismiss'];
|
|
6651
|
+
const ok = await confirmModal({ title, message: `"${name}" has ${has}. ${verb} and dismiss the session?`, okLabel });
|
|
6652
|
+
if (!ok) return false;
|
|
6653
|
+
if (sticky) unpinSession(sid);
|
|
6654
|
+
if (terminal) closeTerminalSession(sid);
|
|
6655
|
+
return true;
|
|
6656
|
+
}
|
|
6657
|
+
|
|
6658
|
+
async function dismissSession(sid) {
|
|
6659
|
+
if (await confirmDismissBlockers(sid)) dismissSessionFromList(sid);
|
|
6660
|
+
}
|
|
6661
|
+
|
|
6466
6662
|
const MODAL_ESC_PRIORITY = [
|
|
6467
6663
|
'preview-modal',
|
|
6468
6664
|
'msg-detail-modal',
|
|
@@ -6488,6 +6684,7 @@ const MODAL_CLOSERS = {
|
|
|
6488
6684
|
'project-picker-modal': () => closeProjectPicker(),
|
|
6489
6685
|
'terminal-manager-modal': () => closeTerminalManager(),
|
|
6490
6686
|
'new-session-modal': () => closeNewSession(),
|
|
6687
|
+
'confirm-modal': () => closeConfirmModal?.(false),
|
|
6491
6688
|
};
|
|
6492
6689
|
|
|
6493
6690
|
document.addEventListener('keydown', (e) => {
|
|
@@ -6645,6 +6842,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
6645
6842
|
}
|
|
6646
6843
|
|
|
6647
6844
|
// Tab toggles focus zone
|
|
6845
|
+
if (e.key === 'Tab' && detailPanel.contains(e.target)) return;
|
|
6648
6846
|
if (e.key === 'Tab') {
|
|
6649
6847
|
e.preventDefault();
|
|
6650
6848
|
// The terminal replaces the board, so it takes the board's place in the Tab cycle.
|
|
@@ -6795,27 +6993,8 @@ document.addEventListener('keydown', (e) => {
|
|
|
6795
6993
|
}
|
|
6796
6994
|
if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
|
|
6797
6995
|
e.preventDefault();
|
|
6798
|
-
|
|
6799
|
-
|
|
6800
|
-
const newItems = getNavigableItems();
|
|
6801
|
-
const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
|
|
6802
|
-
// If the dismissed session is currently open, navigate to the previous one
|
|
6803
|
-
if (currentSessionId === contextSid || selectedSessionId === contextSid) {
|
|
6804
|
-
selectedSessionId = null;
|
|
6805
|
-
if (targetIdx >= 0) {
|
|
6806
|
-
const targetSid = newItems[targetIdx]?.dataset?.sessionId;
|
|
6807
|
-
if (targetSid) {
|
|
6808
|
-
fetchTasks(targetSid).then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
|
|
6809
|
-
} else {
|
|
6810
|
-
showNoSession();
|
|
6811
|
-
selectSessionByIndex(targetIdx, getNavigableItems());
|
|
6812
|
-
}
|
|
6813
|
-
} else {
|
|
6814
|
-
showNoSession();
|
|
6815
|
-
}
|
|
6816
|
-
} else if (targetIdx >= 0) {
|
|
6817
|
-
selectSessionByIndex(targetIdx, newItems);
|
|
6818
|
-
}
|
|
6996
|
+
if (!contextSid) return;
|
|
6997
|
+
dismissSession(contextSid);
|
|
6819
6998
|
return;
|
|
6820
6999
|
}
|
|
6821
7000
|
if (e.code === 'KeyC' && e.shiftKey && !e.altKey && !e.metaKey) {
|
|
@@ -6867,6 +7046,517 @@ document.addEventListener('keydown', (e) => {
|
|
|
6867
7046
|
|
|
6868
7047
|
//#endregion
|
|
6869
7048
|
|
|
7049
|
+
//#region REVIEW
|
|
7050
|
+
// Comments on rendered content, sent to a session as one batch. Drafts outlive the view in
|
|
7051
|
+
// this tab, keyed by session and source, so closing a preview loses nothing. Highlights use
|
|
7052
|
+
// the CSS Custom Highlight API, which marks ranges without touching the DOM the renderer
|
|
7053
|
+
// built; a draft restored on reopen has no mark and shows in the panel only.
|
|
7054
|
+
//
|
|
7055
|
+
// An HTML preview is an iframe on an opaque origin, so its selection is out of reach from
|
|
7056
|
+
// here. `reviewBridge` runs inside the frame and reports each selection over postMessage;
|
|
7057
|
+
// the frame keeps its own ranges and marks them by id. A page whose CSP forbids inline
|
|
7058
|
+
// scripts gets no comments.
|
|
7059
|
+
const reviewDrafts = new Map();
|
|
7060
|
+
const reviewHighlight = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
|
|
7061
|
+
const reviewHotHighlight = reviewHighlight ? new Highlight() : null;
|
|
7062
|
+
const reviewPendingHighlight = reviewHighlight ? new Highlight() : null;
|
|
7063
|
+
if (reviewHighlight) {
|
|
7064
|
+
CSS.highlights.set('review', reviewHighlight);
|
|
7065
|
+
CSS.highlights.set('review-hot', reviewHotHighlight);
|
|
7066
|
+
CSS.highlights.set('review-pending', reviewPendingHighlight);
|
|
7067
|
+
}
|
|
7068
|
+
const REVIEW_MSG = 'cck-review:';
|
|
7069
|
+
let activeReview = null;
|
|
7070
|
+
let reviewMarkSeq = 0;
|
|
7071
|
+
let reviewPopMode = null;
|
|
7072
|
+
let reviewHoverAt = null;
|
|
7073
|
+
let reviewHoverFrame = 0;
|
|
7074
|
+
|
|
7075
|
+
function reviewKey(sessionId, source) {
|
|
7076
|
+
return `${sessionId}|${source.kind}|${source.path ? canonicalPath(source.path) : source.id || source.label}`;
|
|
7077
|
+
}
|
|
7078
|
+
|
|
7079
|
+
function reviewItems() {
|
|
7080
|
+
return (activeReview && reviewDrafts.get(activeReview.key)) || [];
|
|
7081
|
+
}
|
|
7082
|
+
|
|
7083
|
+
// `context(range)` adds view-specific citation fields to a comment on the host DOM;
|
|
7084
|
+
// `onComment()` runs after a comment is added; `onSent()` runs after a batch is delivered.
|
|
7085
|
+
function mountReview({
|
|
7086
|
+
contentEl,
|
|
7087
|
+
panelEl,
|
|
7088
|
+
hostEl,
|
|
7089
|
+
source,
|
|
7090
|
+
frame,
|
|
7091
|
+
context,
|
|
7092
|
+
onComment,
|
|
7093
|
+
onSent,
|
|
7094
|
+
sessionId = currentSessionId,
|
|
7095
|
+
}) {
|
|
7096
|
+
unmountReview();
|
|
7097
|
+
if (!sessionId) return;
|
|
7098
|
+
const ctl = new AbortController();
|
|
7099
|
+
const { signal } = ctl;
|
|
7100
|
+
const key = reviewKey(sessionId, source);
|
|
7101
|
+
activeReview = {
|
|
7102
|
+
contentEl,
|
|
7103
|
+
panelEl,
|
|
7104
|
+
hostEl,
|
|
7105
|
+
source,
|
|
7106
|
+
frame,
|
|
7107
|
+
context,
|
|
7108
|
+
onComment,
|
|
7109
|
+
onSent,
|
|
7110
|
+
sessionId,
|
|
7111
|
+
key,
|
|
7112
|
+
ctl,
|
|
7113
|
+
hot: null,
|
|
7114
|
+
};
|
|
7115
|
+
if (!frame) {
|
|
7116
|
+
contentEl.addEventListener('mouseup', () => setTimeout(offerReviewComment, 0), { signal });
|
|
7117
|
+
contentEl.addEventListener('mousemove', (e) => queueReviewHover(e.clientX, e.clientY), { signal });
|
|
7118
|
+
}
|
|
7119
|
+
window.addEventListener('message', onReviewFrameMessage, { signal });
|
|
7120
|
+
document.addEventListener('mousedown', closeReviewPop, { signal });
|
|
7121
|
+
document.addEventListener('keydown', onReviewOfferKey, { capture: true, signal });
|
|
7122
|
+
document.addEventListener('scroll', closeReviewOffer, { capture: true, signal });
|
|
7123
|
+
panelEl.addEventListener('click', onReviewPanelClick, { signal });
|
|
7124
|
+
panelEl.addEventListener('mouseover', onReviewPanelHover, { signal });
|
|
7125
|
+
panelEl.addEventListener('mouseleave', () => setReviewHot(null), { signal });
|
|
7126
|
+
renderReviewPanel();
|
|
7127
|
+
}
|
|
7128
|
+
|
|
7129
|
+
// With `ownPanel`, unmounts only the review that panel holds, so a modal under another one keeps it.
|
|
7130
|
+
function unmountReview(ownPanel) {
|
|
7131
|
+
if (ownPanel && activeReview?.panelEl !== ownPanel) return;
|
|
7132
|
+
closeReviewPop();
|
|
7133
|
+
if (!activeReview) return;
|
|
7134
|
+
const { ctl, panelEl, hostEl } = activeReview;
|
|
7135
|
+
ctl.abort();
|
|
7136
|
+
for (const c of reviewItems()) {
|
|
7137
|
+
c.range = null;
|
|
7138
|
+
c.frameId = 0;
|
|
7139
|
+
}
|
|
7140
|
+
panelEl.replaceChildren();
|
|
7141
|
+
panelEl.hidden = true;
|
|
7142
|
+
hostEl?.classList.remove('has-review');
|
|
7143
|
+
activeReview = null;
|
|
7144
|
+
reviewHighlight?.clear();
|
|
7145
|
+
reviewHotHighlight?.clear();
|
|
7146
|
+
}
|
|
7147
|
+
|
|
7148
|
+
// The three helpers below are also handed to `reviewBridge` as source text, so each must use
|
|
7149
|
+
// only its arguments and the globals of whichever document it runs in.
|
|
7150
|
+
function reviewTextBefore(root, container, offset) {
|
|
7151
|
+
const before = root.ownerDocument.createRange();
|
|
7152
|
+
before.setStart(root, 0);
|
|
7153
|
+
before.setEnd(container, offset);
|
|
7154
|
+
return before.toString();
|
|
7155
|
+
}
|
|
7156
|
+
|
|
7157
|
+
function reviewHeadingBefore(root, node) {
|
|
7158
|
+
let found = null;
|
|
7159
|
+
for (const h of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
|
7160
|
+
if (h.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING || h.contains(node)) found = h;
|
|
7161
|
+
else break;
|
|
7162
|
+
}
|
|
7163
|
+
return found?.textContent.trim().slice(0, 200) || undefined;
|
|
7164
|
+
}
|
|
7165
|
+
|
|
7166
|
+
function reviewRangeAt(x, y, entries) {
|
|
7167
|
+
const at = document.caretPositionFromPoint?.(x, y);
|
|
7168
|
+
if (!at?.offsetNode) return null;
|
|
7169
|
+
for (const [key, range] of entries) if (range?.isPointInRange(at.offsetNode, at.offset)) return key;
|
|
7170
|
+
return null;
|
|
7171
|
+
}
|
|
7172
|
+
|
|
7173
|
+
function queueReviewHover(x, y) {
|
|
7174
|
+
reviewHoverAt = [x, y];
|
|
7175
|
+
if (reviewHoverFrame) return;
|
|
7176
|
+
reviewHoverFrame = requestAnimationFrame(() => {
|
|
7177
|
+
reviewHoverFrame = 0;
|
|
7178
|
+
const marked = reviewItems().filter((c) => c.range);
|
|
7179
|
+
if (!marked.length) return;
|
|
7180
|
+
const [hx, hy] = reviewHoverAt;
|
|
7181
|
+
setReviewHot(
|
|
7182
|
+
reviewRangeAt(
|
|
7183
|
+
hx,
|
|
7184
|
+
hy,
|
|
7185
|
+
marked.map((c) => [c, c.range]),
|
|
7186
|
+
),
|
|
7187
|
+
true,
|
|
7188
|
+
);
|
|
7189
|
+
});
|
|
7190
|
+
}
|
|
7191
|
+
|
|
7192
|
+
function setReviewHot(c, fromText) {
|
|
7193
|
+
if (!activeReview || activeReview.hot === c) return;
|
|
7194
|
+
activeReview.hot = c;
|
|
7195
|
+
reviewHotHighlight?.clear();
|
|
7196
|
+
if (c?.range) reviewHotHighlight?.add(c.range);
|
|
7197
|
+
if (activeReview.frame) postToReviewFrame('hot', c?.frameId || 0);
|
|
7198
|
+
const { panelEl } = activeReview;
|
|
7199
|
+
panelEl.querySelector('.review-card.hot')?.classList.remove('hot');
|
|
7200
|
+
const i = reviewItems().indexOf(c);
|
|
7201
|
+
const card = i >= 0 ? panelEl.querySelector(`.review-card[data-i="${i}"]`) : null;
|
|
7202
|
+
card?.classList.add('hot');
|
|
7203
|
+
if (fromText) card?.scrollIntoView({ block: 'nearest' });
|
|
7204
|
+
}
|
|
7205
|
+
|
|
7206
|
+
function reviewSelection() {
|
|
7207
|
+
const sel = window.getSelection();
|
|
7208
|
+
if (!activeReview || !sel || sel.isCollapsed || !sel.rangeCount) return null;
|
|
7209
|
+
const range = sel.getRangeAt(0);
|
|
7210
|
+
if (!activeReview.contentEl.contains(range.commonAncestorContainer)) return null;
|
|
7211
|
+
const quote = sel.toString().trim();
|
|
7212
|
+
if (!quote) return null;
|
|
7213
|
+
return { quote, range: range.cloneRange(), rect: range.getBoundingClientRect() };
|
|
7214
|
+
}
|
|
7215
|
+
|
|
7216
|
+
function onReviewFrameMessage(e) {
|
|
7217
|
+
const frameWin = activeReview?.frame?.contentWindow;
|
|
7218
|
+
const type =
|
|
7219
|
+
typeof e.data?.type === 'string' && e.data.type.startsWith(REVIEW_MSG) && e.data.type.slice(REVIEW_MSG.length);
|
|
7220
|
+
if (!frameWin || e.source !== frameWin || !type) return;
|
|
7221
|
+
if (type === 'clear') return closeReviewOffer();
|
|
7222
|
+
if (type === 'hover') {
|
|
7223
|
+
return setReviewHot((e.data.id && reviewItems().find((c) => c.frameId === e.data.id)) || null, true);
|
|
7224
|
+
}
|
|
7225
|
+
if (type !== 'select' || typeof e.data.quote !== 'string' || !e.data.quote.trim()) return;
|
|
7226
|
+
const box = activeReview.frame.getBoundingClientRect();
|
|
7227
|
+
const r = e.data.rect || {};
|
|
7228
|
+
const rect = { left: box.left + (r.left || 0), top: box.top + (r.top || 0), bottom: box.top + (r.bottom || 0) };
|
|
7229
|
+
const c = e.data.context || {};
|
|
7230
|
+
const context = { heading: c.heading, element: c.element, selector: c.selector };
|
|
7231
|
+
const pos = Number.isFinite(e.data.pos) ? e.data.pos : 0;
|
|
7232
|
+
showReviewOffer({ quote: e.data.quote.trim(), context, pos, rect, inFrame: true });
|
|
7233
|
+
}
|
|
7234
|
+
|
|
7235
|
+
function postToReviewFrame(type, id) {
|
|
7236
|
+
activeReview?.frame?.contentWindow?.postMessage({ type: REVIEW_MSG + type, id }, '*');
|
|
7237
|
+
}
|
|
7238
|
+
|
|
7239
|
+
// Runs inside the preview iframe as source text, so it must not close over anything outside
|
|
7240
|
+
// its own body and arguments.
|
|
7241
|
+
function reviewBridge(textBefore, headingBefore, rangeAt) {
|
|
7242
|
+
const P = 'cck-review:';
|
|
7243
|
+
const marks = new Map();
|
|
7244
|
+
let pending = null;
|
|
7245
|
+
let hotId = 0;
|
|
7246
|
+
let hoverAt = null;
|
|
7247
|
+
let hoverFrame = 0;
|
|
7248
|
+
const hl = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
|
|
7249
|
+
const hot = hl ? new Highlight() : null;
|
|
7250
|
+
const pend = hl ? new Highlight() : null;
|
|
7251
|
+
if (hl) {
|
|
7252
|
+
CSS.highlights.set('cck-review', hl);
|
|
7253
|
+
CSS.highlights.set('cck-review-hot', hot);
|
|
7254
|
+
CSS.highlights.set('cck-review-pending', pend);
|
|
7255
|
+
const style = document.createElement('style');
|
|
7256
|
+
style.textContent =
|
|
7257
|
+
'::highlight(cck-review){background-color:rgba(240,180,41,.35)}::highlight(cck-review-hot),::highlight(cck-review-pending){background-color:rgba(240,180,41,.7)}';
|
|
7258
|
+
(document.head || document.documentElement).append(style);
|
|
7259
|
+
}
|
|
7260
|
+
const send = (msg) => parent.postMessage({ ...msg, type: P + msg.type }, '*');
|
|
7261
|
+
const describe = (node) => {
|
|
7262
|
+
const el = node.nodeType === 1 ? node : node.parentElement;
|
|
7263
|
+
if (!el) return {};
|
|
7264
|
+
const cls = [...el.classList].slice(0, 3);
|
|
7265
|
+
const attrs = [el.tagName.toLowerCase()];
|
|
7266
|
+
if (el.id) attrs.push(`id=${JSON.stringify(el.id)}`);
|
|
7267
|
+
if (cls.length) attrs.push(`class=${JSON.stringify(cls.join(' '))}`);
|
|
7268
|
+
const element = `<${attrs.join(' ')}>`;
|
|
7269
|
+
const parts = [];
|
|
7270
|
+
for (let n = el; n && n !== document.body && n.nodeType === 1; n = n.parentElement) {
|
|
7271
|
+
if (n.id) {
|
|
7272
|
+
parts.unshift(`#${n.id}`);
|
|
7273
|
+
break;
|
|
7274
|
+
}
|
|
7275
|
+
const tag = n.tagName.toLowerCase();
|
|
7276
|
+
const same = n.parentElement ? [...n.parentElement.children].filter((s) => s.tagName === n.tagName) : [];
|
|
7277
|
+
parts.unshift(same.length > 1 ? `${tag}:nth-of-type(${same.indexOf(n) + 1})` : tag);
|
|
7278
|
+
}
|
|
7279
|
+
return { element, selector: parts.join(' > '), heading: headingBefore(document, el) };
|
|
7280
|
+
};
|
|
7281
|
+
document.addEventListener('mouseup', () =>
|
|
7282
|
+
setTimeout(() => {
|
|
7283
|
+
const sel = getSelection();
|
|
7284
|
+
const quote = sel && !sel.isCollapsed && sel.rangeCount ? sel.toString().trim() : '';
|
|
7285
|
+
if (!quote) {
|
|
7286
|
+
pending = null;
|
|
7287
|
+
return;
|
|
7288
|
+
}
|
|
7289
|
+
pending = sel.getRangeAt(0).cloneRange();
|
|
7290
|
+
const r = pending.getBoundingClientRect();
|
|
7291
|
+
send({
|
|
7292
|
+
type: 'select',
|
|
7293
|
+
quote,
|
|
7294
|
+
pos: textBefore(document.body, pending.startContainer, pending.startOffset).length,
|
|
7295
|
+
rect: { left: r.left, top: r.top, bottom: r.bottom },
|
|
7296
|
+
context: describe(pending.commonAncestorContainer),
|
|
7297
|
+
});
|
|
7298
|
+
}, 0),
|
|
7299
|
+
);
|
|
7300
|
+
document.addEventListener('mousemove', (e) => {
|
|
7301
|
+
if (!marks.size && !hotId) return;
|
|
7302
|
+
hoverAt = [e.clientX, e.clientY];
|
|
7303
|
+
if (hoverFrame) return;
|
|
7304
|
+
hoverFrame = requestAnimationFrame(() => {
|
|
7305
|
+
hoverFrame = 0;
|
|
7306
|
+
const id = rangeAt(hoverAt[0], hoverAt[1], marks) || 0;
|
|
7307
|
+
if (id !== hotId) {
|
|
7308
|
+
hotId = id;
|
|
7309
|
+
send({ type: 'hover', id });
|
|
7310
|
+
}
|
|
7311
|
+
});
|
|
7312
|
+
});
|
|
7313
|
+
const clear = () => {
|
|
7314
|
+
if (pending) send({ type: 'clear' });
|
|
7315
|
+
};
|
|
7316
|
+
document.addEventListener('mousedown', clear);
|
|
7317
|
+
document.addEventListener('scroll', clear, true);
|
|
7318
|
+
addEventListener('message', (e) => {
|
|
7319
|
+
if (e.source !== parent || typeof e.data?.type !== 'string' || !e.data.type.startsWith(P)) return;
|
|
7320
|
+
const type = e.data.type.slice(P.length);
|
|
7321
|
+
const range = marks.get(e.data.id);
|
|
7322
|
+
if (type === 'mark' && pending) {
|
|
7323
|
+
marks.set(e.data.id, pending);
|
|
7324
|
+
hl?.add(pending);
|
|
7325
|
+
pending = null;
|
|
7326
|
+
getSelection().removeAllRanges();
|
|
7327
|
+
} else if (type === 'pend' && pending) {
|
|
7328
|
+
pend?.add(pending);
|
|
7329
|
+
} else if (type === 'unpend') {
|
|
7330
|
+
pend?.clear();
|
|
7331
|
+
} else if (type === 'hot') {
|
|
7332
|
+
hot?.clear();
|
|
7333
|
+
if (range) hot?.add(range);
|
|
7334
|
+
} else if (type === 'unmark' && range) {
|
|
7335
|
+
hl?.delete(range);
|
|
7336
|
+
hot?.delete(range);
|
|
7337
|
+
marks.delete(e.data.id);
|
|
7338
|
+
} else if (type === 'reveal' && range) {
|
|
7339
|
+
range.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
7340
|
+
}
|
|
7341
|
+
});
|
|
7342
|
+
}
|
|
7343
|
+
|
|
7344
|
+
const REVIEW_BRIDGE_TAG = `<script>(${reviewBridge})(${reviewTextBefore}, ${reviewHeadingBefore}, ${reviewRangeAt});</script>`;
|
|
7345
|
+
|
|
7346
|
+
function reviewPopEl() {
|
|
7347
|
+
let el = document.getElementById('review-pop');
|
|
7348
|
+
if (!el) {
|
|
7349
|
+
el = document.createElement('div');
|
|
7350
|
+
el.id = 'review-pop';
|
|
7351
|
+
el.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
7352
|
+
el.addEventListener('click', (e) => e.stopPropagation());
|
|
7353
|
+
document.body.append(el);
|
|
7354
|
+
}
|
|
7355
|
+
return el;
|
|
7356
|
+
}
|
|
7357
|
+
|
|
7358
|
+
function placeReviewPop(el, r) {
|
|
7359
|
+
el.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - el.offsetWidth - 8))}px`;
|
|
7360
|
+
const above = r.top - el.offsetHeight - 6;
|
|
7361
|
+
el.style.top = `${above >= 8 ? above : r.bottom + 6}px`;
|
|
7362
|
+
}
|
|
7363
|
+
|
|
7364
|
+
function closeReviewPop() {
|
|
7365
|
+
if (!reviewPopMode) return;
|
|
7366
|
+
if (reviewPopMode === 'editor') {
|
|
7367
|
+
reviewPendingHighlight?.clear();
|
|
7368
|
+
if (activeReview?.frame) postToReviewFrame('unpend');
|
|
7369
|
+
}
|
|
7370
|
+
reviewPopMode = null;
|
|
7371
|
+
const el = reviewPopEl();
|
|
7372
|
+
el.className = '';
|
|
7373
|
+
el.replaceChildren();
|
|
7374
|
+
}
|
|
7375
|
+
|
|
7376
|
+
function closeReviewOffer() {
|
|
7377
|
+
if (reviewPopMode === 'offer') closeReviewPop();
|
|
7378
|
+
}
|
|
7379
|
+
|
|
7380
|
+
function offerReviewComment() {
|
|
7381
|
+
const picked = reviewSelection();
|
|
7382
|
+
if (picked) showReviewOffer(picked);
|
|
7383
|
+
else if (reviewPopMode !== 'editor') closeReviewPop();
|
|
7384
|
+
}
|
|
7385
|
+
|
|
7386
|
+
function showReviewOffer(picked) {
|
|
7387
|
+
const el = reviewPopEl();
|
|
7388
|
+
reviewPopMode = 'offer';
|
|
7389
|
+
el.className = 'visible offer';
|
|
7390
|
+
el.innerHTML = `<button type="button" class="review-offer-btn" title="Comment (C)">${ICON_CHAT}Comment<kbd>C</kbd></button>`;
|
|
7391
|
+
el.firstElementChild.addEventListener('click', () => openReviewEditor(picked));
|
|
7392
|
+
placeReviewPop(el, picked.rect);
|
|
7393
|
+
}
|
|
7394
|
+
|
|
7395
|
+
function onReviewOfferKey(e) {
|
|
7396
|
+
if (reviewPopMode !== 'offer' || !matchKey(e, 'c', 'C')) return;
|
|
7397
|
+
e.preventDefault();
|
|
7398
|
+
e.stopImmediatePropagation();
|
|
7399
|
+
reviewPopEl().querySelector('.review-offer-btn').click();
|
|
7400
|
+
}
|
|
7401
|
+
|
|
7402
|
+
function openReviewEditor(picked) {
|
|
7403
|
+
const el = reviewPopEl();
|
|
7404
|
+
reviewPopMode = 'editor';
|
|
7405
|
+
el.className = 'visible editor';
|
|
7406
|
+
if (picked.range) reviewPendingHighlight?.add(picked.range);
|
|
7407
|
+
else if (picked.inFrame) postToReviewFrame('pend');
|
|
7408
|
+
el.innerHTML = `<textarea rows="3" placeholder="Comment (Ctrl+Enter to add)"></textarea>
|
|
7409
|
+
<div class="review-pop-actions"><button type="button" data-act="cancel">Cancel</button><button type="button" data-act="add" class="primary">Add</button></div>`;
|
|
7410
|
+
const ta = el.querySelector('textarea');
|
|
7411
|
+
const add = () => {
|
|
7412
|
+
const comment = ta.value.trim();
|
|
7413
|
+
if (!comment) return ta.focus();
|
|
7414
|
+
addReviewComment(picked, comment);
|
|
7415
|
+
};
|
|
7416
|
+
ta.addEventListener('keydown', (e) => {
|
|
7417
|
+
e.stopPropagation();
|
|
7418
|
+
if (e.key === 'Escape') closeReviewPop();
|
|
7419
|
+
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) add();
|
|
7420
|
+
});
|
|
7421
|
+
el.querySelector('[data-act="cancel"]').addEventListener('click', closeReviewPop);
|
|
7422
|
+
el.querySelector('[data-act="add"]').addEventListener('click', add);
|
|
7423
|
+
placeReviewPop(el, picked.rect);
|
|
7424
|
+
ta.focus();
|
|
7425
|
+
}
|
|
7426
|
+
|
|
7427
|
+
function addReviewComment(picked, comment) {
|
|
7428
|
+
if (!activeReview) return;
|
|
7429
|
+
const { key, contentEl } = activeReview;
|
|
7430
|
+
let item;
|
|
7431
|
+
if (picked.inFrame) {
|
|
7432
|
+
item = {
|
|
7433
|
+
quote: picked.quote,
|
|
7434
|
+
comment,
|
|
7435
|
+
context: picked.context,
|
|
7436
|
+
pos: picked.pos,
|
|
7437
|
+
range: null,
|
|
7438
|
+
frameId: ++reviewMarkSeq,
|
|
7439
|
+
};
|
|
7440
|
+
postToReviewFrame('mark', item.frameId);
|
|
7441
|
+
} else {
|
|
7442
|
+
const { range } = picked;
|
|
7443
|
+
const context = {
|
|
7444
|
+
heading: reviewHeadingBefore(contentEl, range.startContainer),
|
|
7445
|
+
...activeReview.context?.(range),
|
|
7446
|
+
};
|
|
7447
|
+
const pos = reviewTextBefore(contentEl, range.startContainer, range.startOffset).length;
|
|
7448
|
+
item = { quote: picked.quote, comment, context, pos, range, frameId: 0 };
|
|
7449
|
+
reviewHighlight?.add(range);
|
|
7450
|
+
window.getSelection()?.removeAllRanges();
|
|
7451
|
+
}
|
|
7452
|
+
if (!reviewDrafts.has(key)) reviewDrafts.set(key, []);
|
|
7453
|
+
const items = reviewDrafts.get(key);
|
|
7454
|
+
items.push(item);
|
|
7455
|
+
items.sort((a, b) => a.pos - b.pos);
|
|
7456
|
+
closeReviewPop();
|
|
7457
|
+
renderReviewPanel();
|
|
7458
|
+
activeReview.onComment?.();
|
|
7459
|
+
}
|
|
7460
|
+
|
|
7461
|
+
function unmarkReviewItem(c) {
|
|
7462
|
+
if (c.range) reviewHighlight?.delete(c.range);
|
|
7463
|
+
else if (c.frameId) postToReviewFrame('unmark', c.frameId);
|
|
7464
|
+
}
|
|
7465
|
+
|
|
7466
|
+
function revealReviewItem(c) {
|
|
7467
|
+
if (c.range) c.range.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
7468
|
+
else if (c.frameId) postToReviewFrame('reveal', c.frameId);
|
|
7469
|
+
}
|
|
7470
|
+
|
|
7471
|
+
function removeReviewComment(c) {
|
|
7472
|
+
const items = reviewItems();
|
|
7473
|
+
const i = items.indexOf(c);
|
|
7474
|
+
if (i < 0) return;
|
|
7475
|
+
items.splice(i, 1);
|
|
7476
|
+
unmarkReviewItem(c);
|
|
7477
|
+
if (activeReview.hot === c) setReviewHot(null);
|
|
7478
|
+
if (!items.length) reviewDrafts.delete(activeReview.key);
|
|
7479
|
+
renderReviewPanel();
|
|
7480
|
+
}
|
|
7481
|
+
|
|
7482
|
+
function clearReviewDrafts(key = activeReview?.key) {
|
|
7483
|
+
for (const c of reviewDrafts.get(key) || []) unmarkReviewItem(c);
|
|
7484
|
+
reviewDrafts.delete(key);
|
|
7485
|
+
setReviewHot(null);
|
|
7486
|
+
renderReviewPanel();
|
|
7487
|
+
}
|
|
7488
|
+
|
|
7489
|
+
function onReviewPanelClick(e) {
|
|
7490
|
+
const act = e.target.closest('[data-act]')?.dataset.act;
|
|
7491
|
+
if (act === 'send') return sendReview();
|
|
7492
|
+
if (act === 'discard') return clearReviewDrafts();
|
|
7493
|
+
const card = e.target.closest('.review-card');
|
|
7494
|
+
const c = card && reviewItems()[Number(card.dataset.i)];
|
|
7495
|
+
if (!c) return;
|
|
7496
|
+
if (e.target.closest('.review-remove')) removeReviewComment(c);
|
|
7497
|
+
else revealReviewItem(c);
|
|
7498
|
+
}
|
|
7499
|
+
|
|
7500
|
+
function onReviewPanelHover(e) {
|
|
7501
|
+
const card = e.target.closest('.review-card');
|
|
7502
|
+
setReviewHot((card && reviewItems()[Number(card.dataset.i)]) || null);
|
|
7503
|
+
}
|
|
7504
|
+
|
|
7505
|
+
function renderReviewPanel() {
|
|
7506
|
+
if (!activeReview) return;
|
|
7507
|
+
const { panelEl, hostEl } = activeReview;
|
|
7508
|
+
const items = reviewItems();
|
|
7509
|
+
panelEl.hidden = !items.length;
|
|
7510
|
+
hostEl?.classList.toggle('has-review', items.length > 0);
|
|
7511
|
+
if (!items.length) return panelEl.replaceChildren();
|
|
7512
|
+
panelEl.innerHTML = `<div class="review-panel-head">
|
|
7513
|
+
<span>${items.length} comment${items.length > 1 ? 's' : ''}</span>
|
|
7514
|
+
<button type="button" data-act="discard" class="review-discard">Discard</button>
|
|
7515
|
+
<button type="button" data-act="send" class="primary">Send</button>
|
|
7516
|
+
</div>
|
|
7517
|
+
<ol class="review-panel-list">${items
|
|
7518
|
+
.map((c, i) => {
|
|
7519
|
+
return `<li class="review-card${c === activeReview.hot ? ' hot' : ''}" data-i="${i}" title="Show in the document">
|
|
7520
|
+
<button type="button" class="review-remove" title="Remove" aria-label="Remove">×</button>
|
|
7521
|
+
<div class="review-card-quote">${escapeHtml(c.quote.slice(0, 300))}</div>
|
|
7522
|
+
<div class="review-card-comment">${escapeHtml(c.comment)}</div></li>`;
|
|
7523
|
+
})
|
|
7524
|
+
.join('')}</ol>`;
|
|
7525
|
+
}
|
|
7526
|
+
|
|
7527
|
+
async function sendReview() {
|
|
7528
|
+
if (!activeReview) return;
|
|
7529
|
+
const { sessionId, source, key, onSent } = activeReview;
|
|
7530
|
+
const items = reviewItems();
|
|
7531
|
+
if (!items.length) return;
|
|
7532
|
+
try {
|
|
7533
|
+
const res = await terminalFetch(`/api/sessions/${encodeURIComponent(sessionId)}/review`, 'POST', {
|
|
7534
|
+
source,
|
|
7535
|
+
comments: items.map(({ quote, comment, context }) => ({ quote, comment, ...context })),
|
|
7536
|
+
});
|
|
7537
|
+
const data = await res.json();
|
|
7538
|
+
if (!res.ok) return showToast(data.error || 'Review failed', 'error');
|
|
7539
|
+
if (data.delivered === 'doorbell') showToast('Sent to session', 'success');
|
|
7540
|
+
else if (data.delivered === 'terminal') showToast('Pasted into the terminal: press Enter there to send', 'info');
|
|
7541
|
+
else {
|
|
7542
|
+
const { markdown } = data;
|
|
7543
|
+
const copy = () => navigator.clipboard.writeText(markdown).catch(() => {});
|
|
7544
|
+
await copy();
|
|
7545
|
+
showToast(
|
|
7546
|
+
'Review copied. Paste it into the session.',
|
|
7547
|
+
'info',
|
|
7548
|
+
{ label: 'Copy again', onClick: copy },
|
|
7549
|
+
'Run /claude-code-kanban:follow there to send reviews directly.',
|
|
7550
|
+
);
|
|
7551
|
+
}
|
|
7552
|
+
clearReviewDrafts(key);
|
|
7553
|
+
if (activeReview?.key === key) onSent?.();
|
|
7554
|
+
} catch {
|
|
7555
|
+
showToast('Failed to send the review', 'error');
|
|
7556
|
+
}
|
|
7557
|
+
}
|
|
7558
|
+
//#endregion
|
|
7559
|
+
|
|
6870
7560
|
//#region MARKDOWN_PREVIEW
|
|
6871
7561
|
const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
|
|
6872
7562
|
let currentPreviewPath = null;
|
|
@@ -6984,7 +7674,9 @@ function renderHtmlPreview(bodyEl, content) {
|
|
|
6984
7674
|
frame.className = 'preview-html-frame';
|
|
6985
7675
|
frame.setAttribute('sandbox', 'allow-scripts allow-popups');
|
|
6986
7676
|
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
6987
|
-
|
|
7677
|
+
// After the document's end the parser still adds the script to <body>, and it runs after
|
|
7678
|
+
// the page's own scripts.
|
|
7679
|
+
frame.srcdoc = content + REVIEW_BRIDGE_TAG;
|
|
6988
7680
|
bodyEl.appendChild(frame);
|
|
6989
7681
|
}
|
|
6990
7682
|
|
|
@@ -7048,6 +7740,22 @@ function openPreviewModal(filePath, content, kind) {
|
|
|
7048
7740
|
document.getElementById('preview-modal-meta').textContent = filePath;
|
|
7049
7741
|
document.getElementById('preview-modal').classList.add('visible');
|
|
7050
7742
|
updatePreviewLinkBtn();
|
|
7743
|
+
if (kind === 'image') return unmountReview();
|
|
7744
|
+
mountReview({
|
|
7745
|
+
contentEl: bodyEl,
|
|
7746
|
+
panelEl: document.getElementById('preview-review-panel'),
|
|
7747
|
+
hostEl: document.querySelector('#preview-modal .modal'),
|
|
7748
|
+
source: { kind: 'file', label: fileName, path: filePath },
|
|
7749
|
+
frame: isHtml ? bodyEl.querySelector('iframe') : null,
|
|
7750
|
+
context: kind === 'text' ? (range) => ({ line: sourceLineOf(bodyEl, range) }) : null,
|
|
7751
|
+
onSent: closePreviewModal,
|
|
7752
|
+
});
|
|
7753
|
+
}
|
|
7754
|
+
|
|
7755
|
+
function sourceLineOf(bodyEl, range) {
|
|
7756
|
+
const code = bodyEl.querySelector('.preview-source code');
|
|
7757
|
+
if (!code?.contains(range.startContainer)) return undefined;
|
|
7758
|
+
return reviewTextBefore(code, range.startContainer, range.startOffset).split('\n').length;
|
|
7051
7759
|
}
|
|
7052
7760
|
|
|
7053
7761
|
function isPreviewLinkedToCurrentSession() {
|
|
@@ -7098,6 +7806,7 @@ function refreshInfoModalLinkedDocs() {
|
|
|
7098
7806
|
}
|
|
7099
7807
|
|
|
7100
7808
|
function closePreviewModal() {
|
|
7809
|
+
unmountReview(document.getElementById('preview-review-panel'));
|
|
7101
7810
|
hideModalOverlay('preview-modal');
|
|
7102
7811
|
// Empty the body so an iframe preview is destroyed and its scripts/timers stop.
|
|
7103
7812
|
document.getElementById('preview-modal-body').innerHTML = '';
|
|
@@ -7965,20 +8674,30 @@ function renderMarkers(markers) {
|
|
|
7965
8674
|
}
|
|
7966
8675
|
|
|
7967
8676
|
function formatTokens(k) {
|
|
7968
|
-
if (k >= 1000) return `${(k / 1000).toFixed(1)}M`;
|
|
8677
|
+
if (k >= 1000) return `${(k / 1000).toFixed(1).replace(/\.0$/, '')}M`;
|
|
7969
8678
|
if (k < 1) return (k * 1000).toFixed(0);
|
|
7970
8679
|
return `${Math.round(k)}K`;
|
|
7971
8680
|
}
|
|
7972
8681
|
|
|
7973
|
-
|
|
8682
|
+
// session.autoCompact ({window, pct} from the session's settings) makes the compaction point the
|
|
8683
|
+
// bar's full scale, because the status line reports the model's full window regardless.
|
|
8684
|
+
function getCtx(session) {
|
|
8685
|
+
const raw = session?.contextStatus;
|
|
8686
|
+
const autoCompact = session?.autoCompact;
|
|
7974
8687
|
if (!raw) return null;
|
|
7975
8688
|
const cw = raw.context_window || {};
|
|
7976
|
-
const
|
|
7977
|
-
const pct = cw.used_percentage || 0;
|
|
8689
|
+
const modelSize = cw.context_window_size || 0;
|
|
7978
8690
|
const model = raw.model || {};
|
|
7979
8691
|
const modelName = model.display_name || model.id || '';
|
|
7980
8692
|
const thresholds = getModelThresholds(modelName);
|
|
7981
|
-
const usedTokens =
|
|
8693
|
+
const usedTokens = modelSize > 0 ? ((cw.used_percentage || 0) / 100) * modelSize : 0;
|
|
8694
|
+
const compactAt =
|
|
8695
|
+
modelSize > 0 && autoCompact
|
|
8696
|
+
? (Math.min(modelSize, autoCompact.window || modelSize) * (autoCompact.pct || 100)) / 100
|
|
8697
|
+
: 0;
|
|
8698
|
+
const compacts = compactAt > 0 && compactAt < modelSize;
|
|
8699
|
+
const size = compacts ? compactAt : modelSize;
|
|
8700
|
+
const pct = compacts ? Math.min(100, (usedTokens / size) * 100) : cw.used_percentage || 0;
|
|
7982
8701
|
const markers =
|
|
7983
8702
|
size > 0
|
|
7984
8703
|
? [
|
|
@@ -7989,8 +8708,10 @@ function getCtx(raw) {
|
|
|
7989
8708
|
: [];
|
|
7990
8709
|
return {
|
|
7991
8710
|
pct,
|
|
7992
|
-
remaining:
|
|
8711
|
+
remaining: 100 - pct,
|
|
7993
8712
|
size,
|
|
8713
|
+
modelSize,
|
|
8714
|
+
compacts,
|
|
7994
8715
|
usedTokens,
|
|
7995
8716
|
modelName,
|
|
7996
8717
|
inputTokens: cw.total_input_tokens || 0,
|
|
@@ -7999,18 +8720,23 @@ function getCtx(raw) {
|
|
|
7999
8720
|
};
|
|
8000
8721
|
}
|
|
8001
8722
|
|
|
8002
|
-
function renderContextBar(
|
|
8003
|
-
const ctx = getCtx(
|
|
8723
|
+
function renderContextBar(session) {
|
|
8724
|
+
const ctx = getCtx(session);
|
|
8004
8725
|
if (!ctx) return '';
|
|
8005
8726
|
const color = getContextColor(ctx.usedTokens, ctx.modelName);
|
|
8727
|
+
const fmt = (n) => formatTokens(n / 1000);
|
|
8728
|
+
const used = fmt(ctx.usedTokens);
|
|
8729
|
+
const title = ctx.compacts
|
|
8730
|
+
? `Context: ${used} of ${fmt(ctx.size)} tokens before auto-compact (model window ${fmt(ctx.modelSize)})`
|
|
8731
|
+
: `Context: ${used}${ctx.size ? ` of ${fmt(ctx.size)}` : ''} tokens`;
|
|
8006
8732
|
return `
|
|
8007
|
-
<div class="context-bar"
|
|
8733
|
+
<div class="context-bar" title="${escapeHtml(title)}">
|
|
8008
8734
|
<div class="context-bar-track">
|
|
8009
8735
|
<div class="context-bar-fill" style="width:${ctx.pct}%;background:${color}"></div>
|
|
8010
8736
|
${renderMarkers(ctx.markers)}
|
|
8011
8737
|
</div>
|
|
8012
8738
|
<div class="context-bar-labels">
|
|
8013
|
-
<span style="color:${color}">${Math.round(ctx.pct)}% (${
|
|
8739
|
+
<span style="color:${color}">${Math.round(ctx.pct)}% (${used})</span>
|
|
8014
8740
|
<span>${Math.round(ctx.remaining)}% free</span>
|
|
8015
8741
|
</div>
|
|
8016
8742
|
</div>`;
|
|
@@ -8021,9 +8747,10 @@ function formatCost(usd) {
|
|
|
8021
8747
|
return `$${usd.toFixed(2)}`;
|
|
8022
8748
|
}
|
|
8023
8749
|
|
|
8024
|
-
function renderContextDetail(
|
|
8025
|
-
const ctx = getCtx(
|
|
8750
|
+
function renderContextDetail(session, { tokens = true } = {}) {
|
|
8751
|
+
const ctx = getCtx(session);
|
|
8026
8752
|
if (!ctx) return '';
|
|
8753
|
+
const raw = session.contextStatus;
|
|
8027
8754
|
const totalK = ctx.size / 1000;
|
|
8028
8755
|
const color = getContextColor(ctx.usedTokens, ctx.modelName);
|
|
8029
8756
|
|
|
@@ -8052,7 +8779,7 @@ function renderContextDetail(raw, { tokens = true } = {}) {
|
|
|
8052
8779
|
</div>
|
|
8053
8780
|
<div class="detail-context-summary">
|
|
8054
8781
|
<span style="color:${color}">${Math.round(ctx.pct)}% used</span>
|
|
8055
|
-
<span
|
|
8782
|
+
<span${ctx.compacts ? ` title="Auto-compact window · model window ${escapeHtml(formatTokens(ctx.modelSize / 1000))}"` : ''}>${formatTokens(ctx.usedTokens / 1000)} / ${formatTokens(totalK)}</span>
|
|
8056
8783
|
</div>
|
|
8057
8784
|
<div class="detail-context-stats">
|
|
8058
8785
|
${tokenRows}
|
|
@@ -8223,6 +8950,7 @@ function submitWaitingAnswers() {
|
|
|
8223
8950
|
function showWaitingDetail() {
|
|
8224
8951
|
if (!isWaitingFresh()) return;
|
|
8225
8952
|
currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
|
|
8953
|
+
unmountMsgReview();
|
|
8226
8954
|
msgHighlightDimmed = false;
|
|
8227
8955
|
highlightSelectedMsg();
|
|
8228
8956
|
// Follow-mode poll ticks re-run this whole render — carry any typed reject
|
|
@@ -8278,9 +9006,8 @@ function showWaitingDetail() {
|
|
|
8278
9006
|
document.getElementById('msg-detail-agent-btn').style.display = 'none';
|
|
8279
9007
|
const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
|
|
8280
9008
|
autoSizeModal(modal, body);
|
|
8281
|
-
|
|
9009
|
+
setMsgDetailFollow(msgDetailFollowLatest);
|
|
8282
9010
|
const overlay = document.getElementById('msg-detail-modal');
|
|
8283
|
-
overlay.classList.toggle('live-overlay', msgDetailFollowLatest);
|
|
8284
9011
|
const meta = [formatDate(currentWaiting.timestamp), 'waiting'];
|
|
8285
9012
|
document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
|
|
8286
9013
|
currentPinDetailId = null;
|
|
@@ -8292,6 +9019,11 @@ function isSessionActive(s) {
|
|
|
8292
9019
|
return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
|
|
8293
9020
|
}
|
|
8294
9021
|
|
|
9022
|
+
// An active pinned session renders outside the Pinned group until the user dismisses it.
|
|
9023
|
+
function isInPinnedGroup(s) {
|
|
9024
|
+
return isPlacedPinned(s.id) && !isPlacedSticky(s.id) && (!isSessionActive(s) || dismissedSessionIds.has(s.id));
|
|
9025
|
+
}
|
|
9026
|
+
|
|
8295
9027
|
const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8296
9028
|
|
|
8297
9029
|
function sessionDisplayName(session) {
|
|
@@ -8927,13 +9659,13 @@ function projectFilterText() {
|
|
|
8927
9659
|
}
|
|
8928
9660
|
|
|
8929
9661
|
let filterMatcher = { query: null, match: null };
|
|
8930
|
-
function matchesProjectFilter(project) {
|
|
9662
|
+
function matchesProjectFilter(project, repo) {
|
|
8931
9663
|
if (!filterProject) return true;
|
|
8932
9664
|
if (filterProject === '__recent__') return recentProjects.has(project);
|
|
8933
9665
|
if (filterMatcher.query !== filterProject) {
|
|
8934
9666
|
filterMatcher = { query: filterProject, match: projectMatcher(filterProject) };
|
|
8935
9667
|
}
|
|
8936
|
-
return filterMatcher.match(project);
|
|
9668
|
+
return filterMatcher.match(project, repo);
|
|
8937
9669
|
}
|
|
8938
9670
|
|
|
8939
9671
|
//#endregion
|
|
@@ -9108,7 +9840,9 @@ async function refreshProjectList() {
|
|
|
9108
9840
|
const cutoff = Date.now() - RECENT_PROJECT_HOURS * 60 * 60 * 1000;
|
|
9109
9841
|
const prevRecent = recentProjects;
|
|
9110
9842
|
recentProjects = new Set(
|
|
9111
|
-
projects
|
|
9843
|
+
projects
|
|
9844
|
+
.filter((p) => p.modifiedAt && new Date(p.modifiedAt).getTime() > cutoff)
|
|
9845
|
+
.flatMap((p) => [p.path, ...(p.worktrees || [])]),
|
|
9112
9846
|
);
|
|
9113
9847
|
|
|
9114
9848
|
renderFilterState();
|
|
@@ -9394,7 +10128,10 @@ async function showSessionInfoModal(sessionId) {
|
|
|
9394
10128
|
const planPromise = fetch(`/api/sessions/${sessionId}/plan`)
|
|
9395
10129
|
.then((r) => (r.ok ? r.json() : null))
|
|
9396
10130
|
.catch(() => null)
|
|
9397
|
-
.then((data) =>
|
|
10131
|
+
.then((data) => {
|
|
10132
|
+
if (data?.content) _pendingPlanPath = data.path || null;
|
|
10133
|
+
return data?.content || null;
|
|
10134
|
+
});
|
|
9398
10135
|
|
|
9399
10136
|
const tasksPromise =
|
|
9400
10137
|
cachedTasks.length > 0
|
|
@@ -9418,6 +10155,7 @@ async function showSessionInfoModal(sessionId) {
|
|
|
9418
10155
|
|
|
9419
10156
|
let _infoModalSessionId = null;
|
|
9420
10157
|
let _pendingPlanContent = null;
|
|
10158
|
+
let _pendingPlanPath = null;
|
|
9421
10159
|
|
|
9422
10160
|
function updateStickyBtnState() {
|
|
9423
10161
|
const stickyBtn = document.getElementById('session-info-sticky-btn');
|
|
@@ -9542,7 +10280,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
9542
10280
|
|
|
9543
10281
|
if (session.contextStatus) {
|
|
9544
10282
|
html += `<hr style="border: none; border-top: 1px solid var(--border); margin: 12px 0;">`;
|
|
9545
|
-
html += renderContextDetail(session
|
|
10283
|
+
html += renderContextDetail(session);
|
|
9546
10284
|
}
|
|
9547
10285
|
|
|
9548
10286
|
if (planContent) {
|
|
@@ -9660,8 +10398,11 @@ function openSessionFromInfo(sessionId) {
|
|
|
9660
10398
|
}
|
|
9661
10399
|
|
|
9662
10400
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
9663
|
-
function toggleDismissSession(sessionId) {
|
|
9664
|
-
|
|
10401
|
+
async function toggleDismissSession(sessionId) {
|
|
10402
|
+
const dismiss = !dismissedSessionIds.has(sessionId);
|
|
10403
|
+
if (dismiss && !(await confirmDismissBlockers(sessionId))) return;
|
|
10404
|
+
if (dismiss) dismissSessionFromList(sessionId);
|
|
10405
|
+
else setSessionDismissed(sessionId, false);
|
|
9665
10406
|
}
|
|
9666
10407
|
|
|
9667
10408
|
function updateDismissBtnState() {
|
|
@@ -10061,10 +10802,11 @@ function refreshOpenPlan() {
|
|
|
10061
10802
|
fetch(`/api/sessions/${_planSessionId}/plan`)
|
|
10062
10803
|
.then((r) => (r.ok ? r.json() : null))
|
|
10063
10804
|
.then((data) => {
|
|
10064
|
-
if (data?.content) {
|
|
10805
|
+
if (data?.content && (data.content !== _pendingPlanContent || data.path !== _pendingPlanPath)) {
|
|
10065
10806
|
_pendingPlanContent = data.content;
|
|
10066
|
-
|
|
10067
|
-
body.innerHTML = renderMarkdown(_pendingPlanContent);
|
|
10807
|
+
_pendingPlanPath = data.path || null;
|
|
10808
|
+
document.getElementById('plan-modal-body').innerHTML = renderMarkdown(_pendingPlanContent);
|
|
10809
|
+
mountPlanReview();
|
|
10068
10810
|
}
|
|
10069
10811
|
})
|
|
10070
10812
|
.catch(() => {});
|
|
@@ -10383,6 +11125,7 @@ function openPlanForSession(sid) {
|
|
|
10383
11125
|
.then((data) => {
|
|
10384
11126
|
if (data?.content) {
|
|
10385
11127
|
_pendingPlanContent = data.content;
|
|
11128
|
+
_pendingPlanPath = data.path || null;
|
|
10386
11129
|
_planSessionId = sid;
|
|
10387
11130
|
openPlanModal();
|
|
10388
11131
|
}
|
|
@@ -10403,10 +11146,12 @@ function openPlanModal() {
|
|
|
10403
11146
|
document.querySelector('#plan-modal .modal-footer').prepend(controls);
|
|
10404
11147
|
}
|
|
10405
11148
|
document.getElementById('plan-modal').classList.add('visible');
|
|
11149
|
+
mountPlanReview();
|
|
10406
11150
|
const keyHandler = (e) => {
|
|
10407
11151
|
if (e.key === 'Escape') {
|
|
10408
11152
|
e.preventDefault();
|
|
10409
11153
|
e.stopPropagation();
|
|
11154
|
+
if (reviewPopMode) return closeReviewPop();
|
|
10410
11155
|
closePlanModal();
|
|
10411
11156
|
document.removeEventListener('keydown', keyHandler, true);
|
|
10412
11157
|
}
|
|
@@ -10414,7 +11159,20 @@ function openPlanModal() {
|
|
|
10414
11159
|
document.addEventListener('keydown', keyHandler, true);
|
|
10415
11160
|
}
|
|
10416
11161
|
|
|
11162
|
+
function mountPlanReview() {
|
|
11163
|
+
if (!_pendingPlanPath || !_planSessionId) return;
|
|
11164
|
+
mountReview({
|
|
11165
|
+
contentEl: document.getElementById('plan-modal-body'),
|
|
11166
|
+
panelEl: document.getElementById('plan-review-panel'),
|
|
11167
|
+
hostEl: document.querySelector('#plan-modal .modal'),
|
|
11168
|
+
source: { kind: 'file', label: pathBasename(_pendingPlanPath), path: _pendingPlanPath },
|
|
11169
|
+
sessionId: _planSessionId,
|
|
11170
|
+
onSent: closePlanModal,
|
|
11171
|
+
});
|
|
11172
|
+
}
|
|
11173
|
+
|
|
10417
11174
|
function closePlanModal() {
|
|
11175
|
+
unmountReview(document.getElementById('plan-review-panel'));
|
|
10418
11176
|
hideModalOverlay('plan-modal');
|
|
10419
11177
|
}
|
|
10420
11178
|
|
|
@@ -10620,31 +11378,44 @@ const TERMINAL_MODES_KEY = 'terminal-sessions';
|
|
|
10620
11378
|
const TERMINAL_FONT_KEY = 'terminal-font-size';
|
|
10621
11379
|
const TERMINAL_FONT_MIN = 8;
|
|
10622
11380
|
const TERMINAL_FONT_MAX = 32;
|
|
10623
|
-
const
|
|
11381
|
+
const TERMINAL_FRAME_ISOLATED_MS = 5000;
|
|
11382
|
+
const TERMINAL_FRAME_LOAD_MS = 10000;
|
|
11383
|
+
const TERMINAL_FRAME_ALIVE_MS = 5000;
|
|
11384
|
+
const TERMINAL_MSG = 'cck-term:';
|
|
10624
11385
|
const TERMINAL_RETRY_MS = [500, 1000, 2000, 4000, 8000];
|
|
10625
11386
|
const TERMINAL_STABLE_MS = 5000;
|
|
10626
11387
|
const STALE_TOKEN_MSG = 'The terminal token is out of date. Reload the hub window.';
|
|
10627
11388
|
// Read at load, before the first updateUrl() rewrites the URL without the fragment.
|
|
10628
11389
|
let terminalToken = readTerminalToken();
|
|
10629
11390
|
const termState = {
|
|
10630
|
-
|
|
10631
|
-
term: null,
|
|
10632
|
-
fit: null,
|
|
10633
|
-
webgl: null,
|
|
10634
|
-
ws: null,
|
|
11391
|
+
socket: null,
|
|
10635
11392
|
sessionId: null,
|
|
11393
|
+
fontSize: null,
|
|
10636
11394
|
syncedView: null,
|
|
10637
11395
|
panelHidden: false,
|
|
10638
11396
|
shown: false,
|
|
10639
11397
|
attached: false,
|
|
10640
11398
|
closeGuard: false,
|
|
10641
|
-
leaving: false,
|
|
10642
11399
|
focusNext: false,
|
|
10643
11400
|
openedByUser: null,
|
|
10644
|
-
ackPending: 0,
|
|
10645
|
-
ackTimer: null,
|
|
10646
11401
|
retryTimer: null,
|
|
10647
11402
|
};
|
|
11403
|
+
const termFrame = {
|
|
11404
|
+
el: null,
|
|
11405
|
+
origin: null,
|
|
11406
|
+
ready: null,
|
|
11407
|
+
resolve: null,
|
|
11408
|
+
inited: false,
|
|
11409
|
+
reload: false,
|
|
11410
|
+
claims: '',
|
|
11411
|
+
theme: '',
|
|
11412
|
+
sameOrigin: false,
|
|
11413
|
+
};
|
|
11414
|
+
|
|
11415
|
+
function resetTerminalFrame(patch) {
|
|
11416
|
+
Object.assign(termFrame, { inited: false, reload: false, claims: '', theme: '', ...patch });
|
|
11417
|
+
}
|
|
11418
|
+
let terminalSocketSeq = 0;
|
|
10648
11419
|
|
|
10649
11420
|
// The hub passes the token in the fragment, which is never sent to a server or logged.
|
|
10650
11421
|
function readTerminalToken() {
|
|
@@ -10704,7 +11475,11 @@ function setTerminalMode(sessionId, on) {
|
|
|
10704
11475
|
}
|
|
10705
11476
|
|
|
10706
11477
|
function wantsTerminal() {
|
|
10707
|
-
return
|
|
11478
|
+
return viewMode === 'session' && wantsTerminalFor(currentSessionId);
|
|
11479
|
+
}
|
|
11480
|
+
|
|
11481
|
+
function wantsTerminalFor(sessionId) {
|
|
11482
|
+
return terminalAvailable() && !!sessionId && terminalModes().has(sessionId);
|
|
10708
11483
|
}
|
|
10709
11484
|
|
|
10710
11485
|
function toggleTerminal() {
|
|
@@ -10725,12 +11500,13 @@ function inPageField(e) {
|
|
|
10725
11500
|
return !!field && !document.getElementById('terminal-pane').contains(field);
|
|
10726
11501
|
}
|
|
10727
11502
|
|
|
10728
|
-
|
|
11503
|
+
// `probe` asks which keys the terminal frame hands back, and the frame sees keys only while it has focus.
|
|
11504
|
+
function terminalShortcut(e, probe = false) {
|
|
10729
11505
|
const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
|
|
10730
11506
|
const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
|
|
10731
11507
|
// Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal.
|
|
10732
11508
|
const zoom = e.key === '_' ? undefined : zoomDelta(e);
|
|
10733
|
-
if (zoom !== undefined &&
|
|
11509
|
+
if (zoom !== undefined && termFrame.inited && (probe || terminalPaneFocused())) {
|
|
10734
11510
|
return () => adjustTerminalFontSize(Math.sign(zoom));
|
|
10735
11511
|
}
|
|
10736
11512
|
if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
|
|
@@ -10809,7 +11585,13 @@ function syncTerminal() {
|
|
|
10809
11585
|
btn.classList.toggle('active', on);
|
|
10810
11586
|
}
|
|
10811
11587
|
sessionView.classList.toggle('terminal-mode', on);
|
|
11588
|
+
pushTerminalClaims();
|
|
10812
11589
|
if (!on) {
|
|
11590
|
+
// A hidden xterm textarea drops focus, but a display:none iframe keeps it and would take keys unseen.
|
|
11591
|
+
if (termState.shown && terminalPaneFocused()) {
|
|
11592
|
+
terminalHadFocus = false;
|
|
11593
|
+
document.activeElement.blur();
|
|
11594
|
+
}
|
|
10813
11595
|
termState.shown = false;
|
|
10814
11596
|
termState.focusNext = false;
|
|
10815
11597
|
termState.openedByUser = null;
|
|
@@ -10854,8 +11636,7 @@ function promptAwaitsUser(sessionId) {
|
|
|
10854
11636
|
|
|
10855
11637
|
function onTerminalShown(focus = true) {
|
|
10856
11638
|
requestAnimationFrame(() => {
|
|
10857
|
-
|
|
10858
|
-
repaintTerminal();
|
|
11639
|
+
terminalFrameSend('refresh');
|
|
10859
11640
|
if (focus) focusTerminalPane();
|
|
10860
11641
|
});
|
|
10861
11642
|
}
|
|
@@ -10863,37 +11644,210 @@ function onTerminalShown(focus = true) {
|
|
|
10863
11644
|
function focusTerminalPane() {
|
|
10864
11645
|
const prompt = document.getElementById('terminal-prompt');
|
|
10865
11646
|
if (prompt.classList.contains('visible')) prompt.querySelector('button')?.focus();
|
|
10866
|
-
else
|
|
11647
|
+
else if (termFrame.inited) {
|
|
11648
|
+
termFrame.el.focus();
|
|
11649
|
+
terminalFrameSend('focus');
|
|
11650
|
+
}
|
|
10867
11651
|
}
|
|
10868
11652
|
|
|
10869
11653
|
function leaveTerminalPane() {
|
|
10870
|
-
|
|
11654
|
+
terminalHadFocus = false;
|
|
10871
11655
|
document.activeElement.blur();
|
|
10872
|
-
termState.leaving = false;
|
|
10873
11656
|
setFocusZone('sidebar');
|
|
10874
11657
|
}
|
|
10875
11658
|
|
|
10876
|
-
|
|
10877
|
-
|
|
10878
|
-
|
|
10879
|
-
|
|
10880
|
-
|
|
10881
|
-
|
|
10882
|
-
|
|
10883
|
-
|
|
10884
|
-
|
|
10885
|
-
|
|
10886
|
-
|
|
10887
|
-
|
|
10888
|
-
|
|
11659
|
+
// Chrome puts frames in one process per site, and a site ignores the port. Served from another
|
|
11660
|
+
// loopback name, the terminal runs in its own renderer, so board work never delays a key or a frame.
|
|
11661
|
+
// localhost maps to [::1], not 127.0.0.1: Chrome reaches localhost over IPv6, and some Windows
|
|
11662
|
+
// machines drop fresh IPv4 loopback connects in bursts, which stalls a socket for seconds.
|
|
11663
|
+
// A board on [::1] keeps the terminal in its process, because frame-ancestors cannot name an IPv6
|
|
11664
|
+
// literal; so does any other host, which has no second name.
|
|
11665
|
+
function terminalFrameOrigin(loc = location) {
|
|
11666
|
+
const other = { localhost: '[::1]', '127.0.0.1': 'localhost' }[loc.hostname];
|
|
11667
|
+
return other ? `${loc.protocol}//${other}${loc.port ? `:${loc.port}` : ''}` : loc.origin;
|
|
11668
|
+
}
|
|
11669
|
+
|
|
11670
|
+
function ensureTerminalFrame() {
|
|
11671
|
+
if (termFrame.ready) return termFrame.ready;
|
|
11672
|
+
const isolated = termFrame.sameOrigin ? location.origin : terminalFrameOrigin();
|
|
11673
|
+
// A host that does not answer on the other name (IPv6 turned off) still gets a terminal, in this
|
|
11674
|
+
// process, and later loads skip the wait.
|
|
11675
|
+
const ready = loadTerminalFrame(isolated, TERMINAL_FRAME_ISOLATED_MS).catch(async (err) => {
|
|
11676
|
+
if (isolated === location.origin) throw err;
|
|
11677
|
+
await loadTerminalFrame(location.origin, TERMINAL_FRAME_LOAD_MS);
|
|
11678
|
+
termFrame.sameOrigin = true;
|
|
11679
|
+
});
|
|
11680
|
+
termFrame.ready = ready;
|
|
11681
|
+
ready.catch(() => {
|
|
11682
|
+
if (termFrame.ready === ready) termFrame.ready = null;
|
|
11683
|
+
});
|
|
11684
|
+
return ready;
|
|
11685
|
+
}
|
|
11686
|
+
|
|
11687
|
+
function loadTerminalFrame(origin, timeoutMs) {
|
|
11688
|
+
termFrame.el?.remove();
|
|
11689
|
+
const el = document.createElement('iframe');
|
|
11690
|
+
el.className = 'terminal-frame';
|
|
11691
|
+
el.title = 'Terminal';
|
|
11692
|
+
el.allow = 'clipboard-read; clipboard-write';
|
|
11693
|
+
el.src = `${origin}/terminal.html#p=${encodeURIComponent(location.origin)}`;
|
|
11694
|
+
resetTerminalFrame({ el, origin });
|
|
11695
|
+
return new Promise((resolve, reject) => {
|
|
11696
|
+
const timer = setTimeout(() => {
|
|
11697
|
+
if (termFrame.el !== el || termFrame.inited) return;
|
|
11698
|
+
el.remove();
|
|
11699
|
+
termFrame.el = null;
|
|
11700
|
+
reject(new Error('The terminal did not load.'));
|
|
11701
|
+
}, timeoutMs);
|
|
11702
|
+
termFrame.resolve = () => {
|
|
11703
|
+
clearTimeout(timer);
|
|
11704
|
+
resolve();
|
|
11705
|
+
};
|
|
11706
|
+
document.getElementById('terminal-host').appendChild(el);
|
|
11707
|
+
});
|
|
11708
|
+
}
|
|
11709
|
+
|
|
11710
|
+
function sendTerminalTheme() {
|
|
11711
|
+
if (!termFrame.inited) return;
|
|
11712
|
+
const options = terminalThemeOptions();
|
|
11713
|
+
const key = JSON.stringify(options);
|
|
11714
|
+
if (key === termFrame.theme) return;
|
|
11715
|
+
termFrame.theme = key;
|
|
11716
|
+
postToTerminalFrame('theme', { options });
|
|
11717
|
+
}
|
|
11718
|
+
|
|
11719
|
+
// Keyed on the colors, and style is watched, because under the hub a theme change lands in two
|
|
11720
|
+
// steps: the class flips while the old pick's inline vars are still on body, then the SDK swaps them.
|
|
11721
|
+
new MutationObserver(sendTerminalTheme).observe(document.body, {
|
|
11722
|
+
attributes: true,
|
|
11723
|
+
attributeFilter: ['class', 'data-color-theme', 'style'],
|
|
11724
|
+
});
|
|
11725
|
+
|
|
11726
|
+
function postToTerminalFrame(t, data) {
|
|
11727
|
+
termFrame.el.contentWindow?.postMessage({ ...data, type: TERMINAL_MSG + t }, termFrame.origin);
|
|
11728
|
+
}
|
|
11729
|
+
|
|
11730
|
+
function terminalFrameSend(t, data) {
|
|
11731
|
+
if (termFrame.inited) postToTerminalFrame(t, data);
|
|
11732
|
+
}
|
|
11733
|
+
|
|
11734
|
+
window.addEventListener('message', (e) => {
|
|
11735
|
+
if (!termFrame.el || e.source !== termFrame.el.contentWindow || e.origin !== termFrame.origin) return;
|
|
11736
|
+
const m = e.data;
|
|
11737
|
+
if (typeof m?.type === 'string' && m.type.startsWith(TERMINAL_MSG))
|
|
11738
|
+
onTerminalFrameMessage(m.type.slice(TERMINAL_MSG.length), m);
|
|
11739
|
+
});
|
|
11740
|
+
|
|
11741
|
+
function onTerminalFrameMessage(t, m) {
|
|
11742
|
+
const socket = termState.socket;
|
|
11743
|
+
const current = socket && m.socketId === socket.id;
|
|
11744
|
+
if (t === 'loaded') {
|
|
11745
|
+
// A second load is the frame reloaded (its context menu), with no terminal and no socket.
|
|
11746
|
+
const themeOptions = terminalThemeOptions();
|
|
11747
|
+
resetTerminalFrame({ reload: termFrame.reload || termFrame.inited, theme: JSON.stringify(themeOptions) });
|
|
11748
|
+
postToTerminalFrame('init', {
|
|
11749
|
+
fontFamily:
|
|
11750
|
+
appConfig.terminal.fontFamily ||
|
|
11751
|
+
getComputedStyle(document.body).getPropertyValue('--font-mono').trim() ||
|
|
11752
|
+
'monospace',
|
|
11753
|
+
fontSize: currentTerminalFontSize(),
|
|
11754
|
+
scrollback: appConfig.terminal.scrollback,
|
|
11755
|
+
themeOptions,
|
|
10889
11756
|
});
|
|
10890
|
-
|
|
10891
|
-
|
|
11757
|
+
} else if (t === 'term') {
|
|
11758
|
+
termFrame.inited = true;
|
|
11759
|
+
pushTerminalClaims();
|
|
11760
|
+
// A theme change between init and now was dropped.
|
|
11761
|
+
sendTerminalTheme();
|
|
11762
|
+
termFrame.resolve?.();
|
|
11763
|
+
if (termFrame.reload) {
|
|
11764
|
+
termFrame.reload = false;
|
|
11765
|
+
reconnectTerminal();
|
|
11766
|
+
}
|
|
11767
|
+
} else if (t === 'key') replayTerminalKey(m);
|
|
11768
|
+
// Chrome does not always fire focusin on the iframe element when its content takes focus.
|
|
11769
|
+
else if (t === 'focused') terminalHadFocus = terminalPaneFocused();
|
|
11770
|
+
else if (t === 'opening') {
|
|
11771
|
+
if (current) clearTimeout(socket.aliveTimer);
|
|
11772
|
+
} else if (t === 'output') {
|
|
11773
|
+
if (current && terminalBootTimer) hideTerminalBoot();
|
|
11774
|
+
} else if (t === 'ws') {
|
|
11775
|
+
if (!current) return;
|
|
11776
|
+
if (m.msg?.t === 'ready') socket.readyAt = Date.now();
|
|
11777
|
+
else if (m.msg?.t === 'error') socket.refused = true;
|
|
11778
|
+
onTerminalMessage(socket.sessionId, m.msg, m.tail);
|
|
11779
|
+
} else if (t === 'close') {
|
|
11780
|
+
if (current) onTerminalSocketClose(socket, m.code);
|
|
11781
|
+
}
|
|
11782
|
+
}
|
|
11783
|
+
|
|
11784
|
+
// The frame hands back the keys the board acts on. Replaying them on a textarea inside the pane gives
|
|
11785
|
+
// the document listeners the target they saw when xterm lived here: the shortcut runs, the SDK
|
|
11786
|
+
// forwards a hub key, and the text-field guard keeps the board's own keys out.
|
|
11787
|
+
function replayTerminalKey(m) {
|
|
11788
|
+
const target = document.getElementById('terminal-key-proxy');
|
|
11789
|
+
target.dispatchEvent(
|
|
11790
|
+
new KeyboardEvent('keydown', {
|
|
11791
|
+
key: m.key,
|
|
11792
|
+
code: m.code,
|
|
11793
|
+
ctrlKey: !!m.ctrl,
|
|
11794
|
+
altKey: !!m.alt,
|
|
11795
|
+
shiftKey: !!m.shift,
|
|
11796
|
+
metaKey: !!m.meta,
|
|
11797
|
+
bubbles: true,
|
|
11798
|
+
cancelable: true,
|
|
11799
|
+
}),
|
|
10892
11800
|
);
|
|
10893
|
-
|
|
10894
|
-
|
|
11801
|
+
}
|
|
11802
|
+
|
|
11803
|
+
// [code, key, key with Shift] on a US layout. Each is probed by code alone and by key alone, so a claim
|
|
11804
|
+
// holds on any layout exactly when the shortcut would. Hub keys are not probed: the frame gets the
|
|
11805
|
+
// hub's combos and matches them with the SDK's comboOf.
|
|
11806
|
+
const TERMINAL_PROBE_KEYS = [
|
|
11807
|
+
...[...'abcdefghijklmnopqrstuvwxyz'].map((k) => [`Key${k.toUpperCase()}`, k, k.toUpperCase()]),
|
|
11808
|
+
...[...')!@#$%^&*('].map((s, n) => [`Digit${n}`, String(n), s]),
|
|
11809
|
+
['Backquote', '`', '~'],
|
|
11810
|
+
['Equal', '=', '+'],
|
|
11811
|
+
['Minus', '-', '_'],
|
|
11812
|
+
['NumpadAdd', '+', '+'],
|
|
11813
|
+
['NumpadSubtract', '-', '-'],
|
|
11814
|
+
['Numpad0', '0', '0'],
|
|
11815
|
+
...['Left', 'Right', 'Up', 'Down'].map((d) => [`Arrow${d}`, `Arrow${d}`, `Arrow${d}`]),
|
|
11816
|
+
];
|
|
11817
|
+
|
|
11818
|
+
function terminalClaims() {
|
|
11819
|
+
const keys = [];
|
|
11820
|
+
for (let bits = 0; bits < 16; bits++) {
|
|
11821
|
+
const mods = { ctrlKey: !!(bits & 1), altKey: !!(bits & 2), shiftKey: !!(bits & 4), metaKey: !!(bits & 8) };
|
|
11822
|
+
for (const [code, base, shifted] of TERMINAL_PROBE_KEYS) {
|
|
11823
|
+
const key = mods.shiftKey ? shifted : base;
|
|
11824
|
+
for (const probe of [
|
|
11825
|
+
{ ...mods, key: 'Unidentified', code },
|
|
11826
|
+
{ ...mods, key, code: '' },
|
|
11827
|
+
]) {
|
|
11828
|
+
if (terminalShortcut(probe, true)) keys.push(probe);
|
|
11829
|
+
}
|
|
11830
|
+
}
|
|
11831
|
+
}
|
|
11832
|
+
return keys;
|
|
11833
|
+
}
|
|
11834
|
+
|
|
11835
|
+
let terminalClaimsQueued = false;
|
|
11836
|
+
|
|
11837
|
+
// The claims depend on state (the hub's welcome, a slow connect, the session), so every change re-sends
|
|
11838
|
+
// them. One state change calls this several times, and the probe is about 1,500 shortcut checks.
|
|
11839
|
+
function pushTerminalClaims() {
|
|
11840
|
+
if (!termFrame.inited || terminalClaimsQueued) return;
|
|
11841
|
+
terminalClaimsQueued = true;
|
|
11842
|
+
queueMicrotask(() => {
|
|
11843
|
+
terminalClaimsQueued = false;
|
|
11844
|
+
if (!termFrame.inited) return;
|
|
11845
|
+
const claims = { keys: terminalClaims(), forward: hub.forwardCombos() };
|
|
11846
|
+
const sig = JSON.stringify(claims);
|
|
11847
|
+
if (sig === termFrame.claims) return;
|
|
11848
|
+
termFrame.claims = sig;
|
|
11849
|
+
terminalFrameSend('claims', claims);
|
|
10895
11850
|
});
|
|
10896
|
-
return termState.loaded;
|
|
10897
11851
|
}
|
|
10898
11852
|
|
|
10899
11853
|
function terminalTheme() {
|
|
@@ -10922,141 +11876,6 @@ function terminalThemeOptions() {
|
|
|
10922
11876
|
};
|
|
10923
11877
|
}
|
|
10924
11878
|
|
|
10925
|
-
// Returning false hands the key back to the page: xterm skips it and the document
|
|
10926
|
-
// listeners (the toggle, hub forwarding, native paste) see it instead.
|
|
10927
|
-
function terminalKeyFilter(e) {
|
|
10928
|
-
if (e.type !== 'keydown') return true;
|
|
10929
|
-
const ctrlOnly = e.ctrlKey && !e.altKey && !e.metaKey;
|
|
10930
|
-
if (terminalShortcut(e)) return false;
|
|
10931
|
-
if (ctrlOnly && e.code === 'KeyC' && (e.shiftKey || termState.term.hasSelection())) {
|
|
10932
|
-
e.preventDefault();
|
|
10933
|
-
navigator.clipboard?.writeText(termState.term.getSelection()).catch(() => {});
|
|
10934
|
-
termState.term.clearSelection();
|
|
10935
|
-
return false;
|
|
10936
|
-
}
|
|
10937
|
-
if (ctrlOnly && e.code === 'KeyV') return false;
|
|
10938
|
-
return !hub.forwards(e);
|
|
10939
|
-
}
|
|
10940
|
-
|
|
10941
|
-
function oscColor(hex) {
|
|
10942
|
-
const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
|
|
10943
|
-
if (!m) return null;
|
|
10944
|
-
return `rgb:${[0, 2, 4].map((i) => m[1].slice(i, i + 2).repeat(2)).join('/')}`;
|
|
10945
|
-
}
|
|
10946
|
-
|
|
10947
|
-
function ensureTerm() {
|
|
10948
|
-
if (termState.term) return termState.term;
|
|
10949
|
-
const cfg = appConfig.terminal;
|
|
10950
|
-
const host = document.getElementById('terminal-host');
|
|
10951
|
-
const fontFamily =
|
|
10952
|
-
cfg.fontFamily || getComputedStyle(document.body).getPropertyValue('--font-mono').trim() || 'monospace';
|
|
10953
|
-
const fontSize = terminalFontSize();
|
|
10954
|
-
const term = new window.Terminal({
|
|
10955
|
-
fontFamily,
|
|
10956
|
-
fontSize,
|
|
10957
|
-
scrollback: cfg.scrollback,
|
|
10958
|
-
cursorBlink: true,
|
|
10959
|
-
allowProposedApi: true,
|
|
10960
|
-
...terminalThemeOptions(),
|
|
10961
|
-
});
|
|
10962
|
-
termState.fit = new window.FitAddon.FitAddon();
|
|
10963
|
-
term.loadAddon(termState.fit);
|
|
10964
|
-
term.loadAddon(new window.Unicode11Addon.Unicode11Addon());
|
|
10965
|
-
term.unicode.activeVersion = '11';
|
|
10966
|
-
term.open(host);
|
|
10967
|
-
try {
|
|
10968
|
-
const gl = new window.WebglAddon.WebglAddon();
|
|
10969
|
-
gl.onContextLoss(() => {
|
|
10970
|
-
gl.dispose();
|
|
10971
|
-
termState.webgl = null;
|
|
10972
|
-
repaintTerminal();
|
|
10973
|
-
});
|
|
10974
|
-
term.loadAddon(gl);
|
|
10975
|
-
termState.webgl = gl;
|
|
10976
|
-
} catch (_) {
|
|
10977
|
-
// No WebGL: xterm falls back to its DOM renderer.
|
|
10978
|
-
}
|
|
10979
|
-
document.fonts
|
|
10980
|
-
?.load(`${fontSize}px ${fontFamily}`)
|
|
10981
|
-
.then(repaintTerminal)
|
|
10982
|
-
.catch(() => {});
|
|
10983
|
-
term.attachCustomKeyEventHandler(terminalKeyFilter);
|
|
10984
|
-
// xterm leaves OSC 10/11 color queries unanswered, so apps that pick a palette from the
|
|
10985
|
-
// background (Claude Code's Auto theme) assume dark even on a light theme.
|
|
10986
|
-
for (const [code, key] of [
|
|
10987
|
-
[10, 'foreground'],
|
|
10988
|
-
[11, 'background'],
|
|
10989
|
-
]) {
|
|
10990
|
-
term.parser.registerOscHandler(code, (data) => {
|
|
10991
|
-
if (data !== '?') return false;
|
|
10992
|
-
const rgb = oscColor(term.options.theme[key]);
|
|
10993
|
-
if (rgb) terminalSend({ t: 'in', d: `\x1b]${code};${rgb}\x1b\\` });
|
|
10994
|
-
return true;
|
|
10995
|
-
});
|
|
10996
|
-
}
|
|
10997
|
-
term.onData((d) => terminalSend({ t: 'in', d }));
|
|
10998
|
-
term.onResize(({ cols, rows }) => terminalSend({ t: 'resize', cols, rows }));
|
|
10999
|
-
// One fit per frame while a drag resizes the host. The atlas is rebuilt only when the host comes
|
|
11000
|
-
// back from 0×0, which is also how a hidden hub iframe or pane shows again.
|
|
11001
|
-
let frame = 0;
|
|
11002
|
-
let visible = false;
|
|
11003
|
-
new ResizeObserver(() => {
|
|
11004
|
-
if (frame) return;
|
|
11005
|
-
frame = requestAnimationFrame(() => {
|
|
11006
|
-
frame = 0;
|
|
11007
|
-
const nowVisible = host.offsetWidth > 0;
|
|
11008
|
-
fitTerminal();
|
|
11009
|
-
if (nowVisible && !visible) repaintTerminal();
|
|
11010
|
-
visible = nowVisible;
|
|
11011
|
-
});
|
|
11012
|
-
}).observe(host);
|
|
11013
|
-
const themeKey = () => `${isLightTheme()}|${document.body.dataset.colorTheme || ''}`;
|
|
11014
|
-
let appliedTheme = themeKey();
|
|
11015
|
-
new MutationObserver(() => {
|
|
11016
|
-
if (themeKey() === appliedTheme) return;
|
|
11017
|
-
appliedTheme = themeKey();
|
|
11018
|
-
Object.assign(term.options, terminalThemeOptions());
|
|
11019
|
-
repaintTerminal();
|
|
11020
|
-
}).observe(document.body, { attributes: true, attributeFilter: ['class', 'data-color-theme'] });
|
|
11021
|
-
// Vimium eats Escape inside a text field and only blurs it, so the key never reaches Claude.
|
|
11022
|
-
// A blur no click caused, while the pane stays shown and the window keeps focus, is that Escape.
|
|
11023
|
-
let pointerDown = false;
|
|
11024
|
-
document.addEventListener('mousedown', () => (pointerDown = true), true);
|
|
11025
|
-
document.addEventListener('mouseup', () => (pointerDown = false), true);
|
|
11026
|
-
term.textarea.addEventListener('blur', () => {
|
|
11027
|
-
if (pointerDown || termState.leaving) return;
|
|
11028
|
-
requestAnimationFrame(() => {
|
|
11029
|
-
if (!termState.attached || !host.offsetWidth) return;
|
|
11030
|
-
if (!document.hasFocus() || document.activeElement !== document.body) return;
|
|
11031
|
-
terminalSend({ t: 'in', d: '\x1b' });
|
|
11032
|
-
term.focus();
|
|
11033
|
-
});
|
|
11034
|
-
});
|
|
11035
|
-
termState.term = term;
|
|
11036
|
-
return term;
|
|
11037
|
-
}
|
|
11038
|
-
|
|
11039
|
-
// The WebGL renderer keeps its last frame and glyph atlas while the pane (or the hub's iframe) is
|
|
11040
|
-
// display:none, and comes back showing stale cells or backgrounds with no text. xterm does not
|
|
11041
|
-
// repaint on its own when the canvas is shown again.
|
|
11042
|
-
function repaintTerminal() {
|
|
11043
|
-
const term = termState.term;
|
|
11044
|
-
if (!term) return;
|
|
11045
|
-
try {
|
|
11046
|
-
termState.webgl?.clearTextureAtlas();
|
|
11047
|
-
} catch (_) {}
|
|
11048
|
-
term.refresh(0, term.rows - 1);
|
|
11049
|
-
}
|
|
11050
|
-
|
|
11051
|
-
// The fit addon measures 0×0 while the pane (or the hub's iframe) is display:none.
|
|
11052
|
-
function fitTerminal() {
|
|
11053
|
-
const host = document.getElementById('terminal-host');
|
|
11054
|
-
if (!termState.fit || !host.offsetWidth || !host.offsetHeight) return;
|
|
11055
|
-
try {
|
|
11056
|
-
termState.fit.fit();
|
|
11057
|
-
} catch (_) {}
|
|
11058
|
-
}
|
|
11059
|
-
|
|
11060
11879
|
function clampTerminalFont(v) {
|
|
11061
11880
|
return Math.min(TERMINAL_FONT_MAX, Math.max(TERMINAL_FONT_MIN, v));
|
|
11062
11881
|
}
|
|
@@ -11066,33 +11885,25 @@ function terminalFontSize() {
|
|
|
11066
11885
|
return saved ? clampTerminalFont(saved) : appConfig.terminal.fontSize;
|
|
11067
11886
|
}
|
|
11068
11887
|
|
|
11888
|
+
function currentTerminalFontSize() {
|
|
11889
|
+
termState.fontSize ??= terminalFontSize();
|
|
11890
|
+
return termState.fontSize;
|
|
11891
|
+
}
|
|
11892
|
+
|
|
11069
11893
|
function adjustTerminalFontSize(step) {
|
|
11070
|
-
const
|
|
11071
|
-
const size = step === 0 ? appConfig.terminal.fontSize : clampTerminalFont(
|
|
11072
|
-
if (size !==
|
|
11894
|
+
const current = currentTerminalFontSize();
|
|
11895
|
+
const size = step === 0 ? appConfig.terminal.fontSize : clampTerminalFont(current + step);
|
|
11896
|
+
if (size !== current) {
|
|
11073
11897
|
if (step === 0) store.removeItem(TERMINAL_FONT_KEY);
|
|
11074
11898
|
else store.setItem(TERMINAL_FONT_KEY, String(size));
|
|
11075
|
-
|
|
11076
|
-
|
|
11899
|
+
termState.fontSize = size;
|
|
11900
|
+
terminalFrameSend('font', { size });
|
|
11077
11901
|
}
|
|
11078
11902
|
showToast(`Terminal ${size}px`);
|
|
11079
11903
|
}
|
|
11080
11904
|
|
|
11081
11905
|
function terminalSend(msg) {
|
|
11082
|
-
if (termState.
|
|
11083
|
-
}
|
|
11084
|
-
|
|
11085
|
-
function ackTerminal(ws, n) {
|
|
11086
|
-
if (termState.ws !== ws) return;
|
|
11087
|
-
termState.ackPending += n;
|
|
11088
|
-
const flush = () => {
|
|
11089
|
-
clearTimeout(termState.ackTimer);
|
|
11090
|
-
termState.ackTimer = null;
|
|
11091
|
-
if (termState.ws === ws && termState.ackPending) terminalSend({ t: 'ack', n: termState.ackPending });
|
|
11092
|
-
termState.ackPending = 0;
|
|
11093
|
-
};
|
|
11094
|
-
if (termState.ackPending >= ACK_BATCH_BYTES) flush();
|
|
11095
|
-
else if (!termState.ackTimer) termState.ackTimer = setTimeout(flush, 50);
|
|
11906
|
+
if (termState.socket) terminalFrameSend('send', { msg });
|
|
11096
11907
|
}
|
|
11097
11908
|
|
|
11098
11909
|
function setTerminalStatus(text) {
|
|
@@ -11132,6 +11943,7 @@ function clearTerminalSlow() {
|
|
|
11132
11943
|
clearTimeout(terminalSlowTimer);
|
|
11133
11944
|
terminalSlowTimer = null;
|
|
11134
11945
|
document.getElementById('terminal-boot').classList.remove('slow');
|
|
11946
|
+
pushTerminalClaims();
|
|
11135
11947
|
}
|
|
11136
11948
|
|
|
11137
11949
|
function terminalConnectSlow() {
|
|
@@ -11160,22 +11972,12 @@ function showTerminalPrompt(sessionId, title, detail, choices, output = '') {
|
|
|
11160
11972
|
el.querySelector('button')?.focus();
|
|
11161
11973
|
}
|
|
11162
11974
|
|
|
11163
|
-
function terminalTail(count) {
|
|
11164
|
-
const buf = termState.term?.buffer.active;
|
|
11165
|
-
if (!buf) return '';
|
|
11166
|
-
const lines = [];
|
|
11167
|
-
for (let i = buf.length - 1; i >= 0 && lines.length < count; i--) {
|
|
11168
|
-
const text = buf.getLine(i)?.translateToString(true) ?? '';
|
|
11169
|
-
if (text.trim() || lines.length) lines.unshift(text);
|
|
11170
|
-
}
|
|
11171
|
-
return lines.join('\n');
|
|
11172
|
-
}
|
|
11173
|
-
|
|
11174
11975
|
// Closing the page leaves the PTY running, but Ctrl+W meant for the prompt closes the tab, so ask first.
|
|
11175
11976
|
// Under the hub the top frame asks: browsers do not reliably show the dialog for a frame.
|
|
11176
11977
|
function setTerminalAttached(on) {
|
|
11177
11978
|
termState.attached = on;
|
|
11178
11979
|
syncCloseGuard();
|
|
11980
|
+
pushTerminalClaims();
|
|
11179
11981
|
}
|
|
11180
11982
|
|
|
11181
11983
|
// A hidden pane stays attached, but Ctrl+W can only be meant for a terminal on screen.
|
|
@@ -11203,6 +12005,26 @@ document.addEventListener('focusout', (e) => {
|
|
|
11203
12005
|
if (document.hasFocus()) terminalHadFocus = terminalPaneFocused();
|
|
11204
12006
|
});
|
|
11205
12007
|
});
|
|
12008
|
+
// The hub hands focus back by focusing this frame after a palette closes, and focus does not
|
|
12009
|
+
// pass on into the nested terminal frame by itself. The hub makes this frame inert while the
|
|
12010
|
+
// palette is open, which can blur the terminal frame, so activeElement no longer says it had focus.
|
|
12011
|
+
// Without inert (the app launcher), activeElement stays the frame element while the frame has no focus.
|
|
12012
|
+
// Chrome fires this before focusin when a click or a focus() call in this page takes focus from the
|
|
12013
|
+
// frame, so it waits a task and gives way to whatever took focus.
|
|
12014
|
+
let boardPointerDown = false;
|
|
12015
|
+
document.addEventListener('pointerdown', () => (boardPointerDown = true), true);
|
|
12016
|
+
for (const type of ['pointerup', 'pointercancel']) {
|
|
12017
|
+
document.addEventListener(type, () => (boardPointerDown = false), true);
|
|
12018
|
+
}
|
|
12019
|
+
window.addEventListener('focus', () => {
|
|
12020
|
+
if (!terminalHadFocus || boardPointerDown) return;
|
|
12021
|
+
setTimeout(() => {
|
|
12022
|
+
const active = document.activeElement;
|
|
12023
|
+
if (terminalHadFocus && termState.shown && (active === document.body || active === termFrame.el)) {
|
|
12024
|
+
focusTerminalPane();
|
|
12025
|
+
}
|
|
12026
|
+
});
|
|
12027
|
+
});
|
|
11206
12028
|
window.addEventListener('pagehide', () => {
|
|
11207
12029
|
try {
|
|
11208
12030
|
if (termState.attached && termState.shown && terminalHadFocus) {
|
|
@@ -11227,109 +12049,87 @@ document.getElementById('terminal-pane').addEventListener('focusin', () => {
|
|
|
11227
12049
|
});
|
|
11228
12050
|
|
|
11229
12051
|
function detachTerminal() {
|
|
11230
|
-
const
|
|
11231
|
-
termState.
|
|
12052
|
+
const had = termState.socket;
|
|
12053
|
+
termState.socket = null;
|
|
11232
12054
|
termState.sessionId = null;
|
|
11233
|
-
clearTimeout(termState.ackTimer);
|
|
11234
|
-
termState.ackTimer = null;
|
|
11235
|
-
termState.ackPending = 0;
|
|
11236
12055
|
clearTimeout(termState.retryTimer);
|
|
11237
12056
|
termState.retryTimer = null;
|
|
11238
12057
|
setTerminalAttached(false);
|
|
11239
|
-
if (
|
|
11240
|
-
ws.onclose = null;
|
|
11241
|
-
ws.close();
|
|
11242
|
-
}
|
|
12058
|
+
if (had) terminalFrameSend('detach');
|
|
11243
12059
|
hideTerminalPrompt();
|
|
11244
12060
|
hideTerminalBoot();
|
|
11245
12061
|
}
|
|
11246
12062
|
|
|
12063
|
+
// The frame owns the socket and xterm; the board picks the session, the mode and when to retry.
|
|
11247
12064
|
async function openTerminal(sessionId, mode, attempt = 0) {
|
|
11248
12065
|
detachTerminal();
|
|
11249
12066
|
termState.sessionId = sessionId;
|
|
12067
|
+
const socket = { id: ++terminalSocketSeq, sessionId, mode, attempt, readyAt: 0, refused: false };
|
|
12068
|
+
termState.socket = socket;
|
|
11250
12069
|
if (!attempt) setTerminalStatus('');
|
|
11251
|
-
|
|
11252
|
-
|
|
11253
|
-
|
|
11254
|
-
|
|
11255
|
-
|
|
12070
|
+
// Even a settled await resumes only after the caller's render, so a loaded frame is used at once.
|
|
12071
|
+
if (!termFrame.inited) {
|
|
12072
|
+
try {
|
|
12073
|
+
await ensureTerminalFrame();
|
|
12074
|
+
} catch (e) {
|
|
12075
|
+
if (termState.socket === socket) setTerminalStatus(e.message);
|
|
12076
|
+
return;
|
|
12077
|
+
}
|
|
12078
|
+
if (termState.socket !== socket) return;
|
|
11256
12079
|
}
|
|
11257
|
-
if (termState.sessionId !== sessionId) return;
|
|
11258
|
-
const term = ensureTerm();
|
|
11259
|
-
if (!attempt) showTerminalBoot();
|
|
11260
|
-
// RIS through the write queue, not term.reset(): reset() runs at once, and output the previous
|
|
11261
|
-
// session had already queued would still be drawn after it. A retry keeps the last screen
|
|
11262
|
-
// until the replay arrives.
|
|
11263
|
-
const reset = () => term.write('\x1bc', repaintTerminal);
|
|
11264
|
-
if (!attempt) reset();
|
|
11265
|
-
fitTerminal();
|
|
11266
|
-
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/terminal/ws`);
|
|
11267
|
-
ws.binaryType = 'arraybuffer';
|
|
11268
|
-
termState.ws = ws;
|
|
11269
12080
|
if (!attempt) {
|
|
12081
|
+
showTerminalBoot();
|
|
11270
12082
|
terminalSlowTimer = setTimeout(() => {
|
|
11271
|
-
if (termState.
|
|
12083
|
+
if (termState.socket !== socket) return;
|
|
12084
|
+
document.getElementById('terminal-boot').classList.add('slow');
|
|
12085
|
+
pushTerminalClaims();
|
|
11272
12086
|
}, TERMINAL_SLOW_CONNECT_MS);
|
|
11273
12087
|
}
|
|
11274
12088
|
const spec = newSpecs.get(sessionId);
|
|
11275
|
-
|
|
11276
|
-
|
|
11277
|
-
|
|
11278
|
-
|
|
11279
|
-
|
|
11280
|
-
|
|
11281
|
-
|
|
11282
|
-
|
|
11283
|
-
|
|
11284
|
-
|
|
11285
|
-
|
|
11286
|
-
|
|
11287
|
-
|
|
11288
|
-
name: spec.name,
|
|
11289
|
-
worktree: spec.worktree,
|
|
11290
|
-
model: spec.model,
|
|
11291
|
-
prompt: spec.prompt,
|
|
11292
|
-
}),
|
|
12089
|
+
terminalFrameSend('open', {
|
|
12090
|
+
socketId: socket.id,
|
|
12091
|
+
reset: !attempt,
|
|
12092
|
+
hello: {
|
|
12093
|
+
token: terminalToken,
|
|
12094
|
+
id: sessionId,
|
|
12095
|
+
mode,
|
|
12096
|
+
...(spec?.mode === mode && {
|
|
12097
|
+
cwd: spec.cwd,
|
|
12098
|
+
name: spec.name,
|
|
12099
|
+
worktree: spec.worktree,
|
|
12100
|
+
model: spec.model,
|
|
12101
|
+
prompt: spec.prompt,
|
|
11293
12102
|
}),
|
|
11294
|
-
|
|
11295
|
-
|
|
11296
|
-
|
|
11297
|
-
|
|
11298
|
-
|
|
11299
|
-
|
|
11300
|
-
|
|
11301
|
-
|
|
11302
|
-
|
|
11303
|
-
|
|
11304
|
-
|
|
11305
|
-
|
|
11306
|
-
|
|
11307
|
-
|
|
11308
|
-
|
|
11309
|
-
|
|
11310
|
-
|
|
11311
|
-
|
|
11312
|
-
|
|
11313
|
-
|
|
11314
|
-
|
|
11315
|
-
|
|
11316
|
-
|
|
11317
|
-
|
|
11318
|
-
|
|
11319
|
-
|
|
11320
|
-
|
|
11321
|
-
|
|
11322
|
-
|
|
11323
|
-
|
|
11324
|
-
refreshTerminalToken().then((ok) => {
|
|
11325
|
-
if (ok && termState.sessionId === sessionId) openTerminal(sessionId, mode, attempt + 1);
|
|
11326
|
-
});
|
|
11327
|
-
return;
|
|
11328
|
-
}
|
|
11329
|
-
if (refused || document.getElementById('terminal-prompt').classList.contains('visible')) return;
|
|
11330
|
-
const stable = readyAt && Date.now() - readyAt > TERMINAL_STABLE_MS;
|
|
11331
|
-
retryTerminal(sessionId, stable ? 0 : attempt);
|
|
11332
|
-
};
|
|
12103
|
+
},
|
|
12104
|
+
});
|
|
12105
|
+
// Chrome shows a crashed frame as a sad face and does not reload it, so a dead frame only shows as silence.
|
|
12106
|
+
socket.aliveTimer = setTimeout(() => {
|
|
12107
|
+
if (termState.socket !== socket) return;
|
|
12108
|
+
dropTerminalFrame();
|
|
12109
|
+
openTerminal(sessionId, mode, attempt);
|
|
12110
|
+
}, TERMINAL_FRAME_ALIVE_MS);
|
|
12111
|
+
}
|
|
12112
|
+
|
|
12113
|
+
function dropTerminalFrame() {
|
|
12114
|
+
termFrame.el?.remove();
|
|
12115
|
+
resetTerminalFrame({ el: null, ready: null });
|
|
12116
|
+
}
|
|
12117
|
+
|
|
12118
|
+
function onTerminalSocketClose(socket, code) {
|
|
12119
|
+
termState.socket = null;
|
|
12120
|
+
setTerminalAttached(false);
|
|
12121
|
+
const { sessionId, mode, attempt } = socket;
|
|
12122
|
+
// A refusal already shows the server's reason, and retrying would only repeat it, unless the
|
|
12123
|
+
// reason was a token that the hub has since replaced (4001).
|
|
12124
|
+
if (socket.refused && code === 4001) {
|
|
12125
|
+
refreshTerminalToken().then((ok) => {
|
|
12126
|
+
if (ok && termState.sessionId === sessionId) openTerminal(sessionId, mode, attempt + 1);
|
|
12127
|
+
});
|
|
12128
|
+
return;
|
|
12129
|
+
}
|
|
12130
|
+
if (socket.refused || document.getElementById('terminal-prompt').classList.contains('visible')) return;
|
|
12131
|
+
const stable = socket.readyAt && Date.now() - socket.readyAt > TERMINAL_STABLE_MS;
|
|
12132
|
+
retryTerminal(sessionId, stable ? 0 : attempt);
|
|
11333
12133
|
}
|
|
11334
12134
|
|
|
11335
12135
|
function terminalOpenMode(sessionId) {
|
|
@@ -11368,7 +12168,7 @@ function retryTerminal(sessionId, attempt) {
|
|
|
11368
12168
|
}, delay);
|
|
11369
12169
|
}
|
|
11370
12170
|
|
|
11371
|
-
function onTerminalMessage(sessionId, msg) {
|
|
12171
|
+
function onTerminalMessage(sessionId, msg, tail = '') {
|
|
11372
12172
|
if (msg.t === 'ready') {
|
|
11373
12173
|
// The server has the prompt now; a later start of the same placeholder must not send it again.
|
|
11374
12174
|
const spec = newSpecs.get(sessionId);
|
|
@@ -11377,8 +12177,7 @@ function onTerminalMessage(sessionId, msg) {
|
|
|
11377
12177
|
hideTerminalPrompt();
|
|
11378
12178
|
setTerminalStatus('');
|
|
11379
12179
|
setTerminalAttached(true);
|
|
11380
|
-
|
|
11381
|
-
if (takeTerminalFocus(sessionId)) termState.term.focus();
|
|
12180
|
+
if (takeTerminalFocus(sessionId)) focusTerminalPane();
|
|
11382
12181
|
} else if (msg.t === 'rekey' && typeof msg.id === 'string') {
|
|
11383
12182
|
adoptPickedSession(sessionId, msg.id);
|
|
11384
12183
|
if (msg.duplicate) showToast('That session is already open here, so this terminal switched to it', 'info');
|
|
@@ -11419,7 +12218,7 @@ function onTerminalMessage(sessionId, msg) {
|
|
|
11419
12218
|
['fork', 'Fork'],
|
|
11420
12219
|
['shell', 'Shell'],
|
|
11421
12220
|
],
|
|
11422
|
-
clean ? '' :
|
|
12221
|
+
clean ? '' : tail,
|
|
11423
12222
|
);
|
|
11424
12223
|
} else if (msg.t === 'error') {
|
|
11425
12224
|
hideTerminalBoot();
|
|
@@ -11449,12 +12248,42 @@ function openTerminalManager() {
|
|
|
11449
12248
|
if (terminalPaneFocused()) leaveTerminalPane();
|
|
11450
12249
|
document.getElementById('terminal-manager-modal').classList.add('visible');
|
|
11451
12250
|
renderTerminalManager();
|
|
12251
|
+
clearInterval(terminalProcTimer);
|
|
12252
|
+
pollTerminalProcStats();
|
|
12253
|
+
terminalProcTimer = setInterval(pollTerminalProcStats, TERMINAL_PROC_POLL_MS);
|
|
11452
12254
|
}
|
|
11453
12255
|
|
|
11454
12256
|
function closeTerminalManager() {
|
|
12257
|
+
clearInterval(terminalProcTimer);
|
|
11455
12258
|
hideModalOverlay('terminal-manager-modal');
|
|
11456
12259
|
}
|
|
11457
12260
|
|
|
12261
|
+
// Each poll costs one OS process query on the server, so it runs only while the modal is on screen.
|
|
12262
|
+
const TERMINAL_PROC_POLL_MS = 5000;
|
|
12263
|
+
let terminalProcTimer = 0;
|
|
12264
|
+
let terminalProcStats = {};
|
|
12265
|
+
|
|
12266
|
+
function formatTerminalProc(s) {
|
|
12267
|
+
if (!s) return '';
|
|
12268
|
+
return `${Math.round(s.rss / 1048576)} MB${s.cpu === null ? '' : ` · ${s.cpu}% CPU`}`;
|
|
12269
|
+
}
|
|
12270
|
+
|
|
12271
|
+
async function pollTerminalProcStats() {
|
|
12272
|
+
if (!isOnScreen()) return;
|
|
12273
|
+
let cck;
|
|
12274
|
+
try {
|
|
12275
|
+
const res = await fetch('/api/terminals/stats', { cache: 'no-store' });
|
|
12276
|
+
if (!res.ok) return;
|
|
12277
|
+
({ cck, terminals: terminalProcStats } = await res.json());
|
|
12278
|
+
} catch (_) {
|
|
12279
|
+
return;
|
|
12280
|
+
}
|
|
12281
|
+
document.getElementById('terminal-manager-stats').textContent = cck ? `cck ${formatTerminalProc(cck)}` : '';
|
|
12282
|
+
for (const el of document.querySelectorAll('#terminal-manager-body [data-proc]')) {
|
|
12283
|
+
el.textContent = formatTerminalProc(terminalProcStats[el.dataset.proc]);
|
|
12284
|
+
}
|
|
12285
|
+
}
|
|
12286
|
+
|
|
11458
12287
|
async function renderTerminalManager() {
|
|
11459
12288
|
const body = document.getElementById('terminal-manager-body');
|
|
11460
12289
|
let list = [];
|
|
@@ -11488,6 +12317,7 @@ async function renderTerminalManager() {
|
|
|
11488
12317
|
<span class="terminal-manager-mode">${escapeHtml(t.mode)}</span>
|
|
11489
12318
|
<span title="${escapeHtml(`pid ${t.pid}`)}">up ${formatDuration(Date.now() - t.startedAt)}</span>
|
|
11490
12319
|
<span>${attached}</span>
|
|
12320
|
+
<span class="terminal-manager-proc" data-proc="${escapeHtml(t.id)}" title="Memory and CPU of the claude process. Tools it runs are not counted. 100% CPU is one core.">${escapeHtml(formatTerminalProc(terminalProcStats[t.id]))}</span>
|
|
11491
12321
|
<span class="terminal-manager-cwd" title="${escapeHtml(t.cwd)}">${escapeHtml(pathBasename(t.cwd))}</span>
|
|
11492
12322
|
</div>
|
|
11493
12323
|
</div>
|
|
@@ -11571,6 +12401,7 @@ function placeholderSession(id, spec) {
|
|
|
11571
12401
|
name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
11572
12402
|
dispatchGroup: spec.group || undefined,
|
|
11573
12403
|
startedBy: spec.startedBy || undefined,
|
|
12404
|
+
dispatched: spec.mode === 'dispatch' ? { parent: spec.startedBy || null } : undefined,
|
|
11574
12405
|
project: spec.cwd,
|
|
11575
12406
|
worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
|
|
11576
12407
|
modifiedAt: new Date(spec.startedAt).toISOString(),
|
|
@@ -11666,12 +12497,14 @@ async function loadTerminals() {
|
|
|
11666
12497
|
const res = await fetch('/api/terminals', { cache: 'no-store' });
|
|
11667
12498
|
const list = (await res.json()).sessions || [];
|
|
11668
12499
|
runningTerminals = new Set(list.map((t) => t.id));
|
|
12500
|
+
pushTerminalClaims();
|
|
11669
12501
|
renderActivityChip();
|
|
11670
12502
|
return list;
|
|
11671
12503
|
}
|
|
11672
12504
|
|
|
11673
12505
|
function setRunningTerminals(ids) {
|
|
11674
12506
|
runningTerminals = new Set(ids);
|
|
12507
|
+
pushTerminalClaims();
|
|
11675
12508
|
if (ids.some((id) => !sessions.some((s) => s.id === id))) fetchSessions(false).catch(() => {});
|
|
11676
12509
|
else renderSessionViews();
|
|
11677
12510
|
renderActivityChip();
|
|
@@ -12234,7 +13067,12 @@ fetch('/api/config')
|
|
|
12234
13067
|
})
|
|
12235
13068
|
.catch(() => {})
|
|
12236
13069
|
.then(restorePendingSessions)
|
|
12237
|
-
.then(() =>
|
|
13070
|
+
.then(() =>
|
|
13071
|
+
fetchSessions(
|
|
13072
|
+
true,
|
|
13073
|
+
urlState.projectView ? null : urlState.session || (lastView?.view === 'session' ? lastView.session : null),
|
|
13074
|
+
),
|
|
13075
|
+
)
|
|
12238
13076
|
.then(async () => {
|
|
12239
13077
|
if (urlState.projectView) {
|
|
12240
13078
|
try {
|
|
@@ -12315,7 +13153,11 @@ const hub = ClaudeHub.connect({
|
|
|
12315
13153
|
}),
|
|
12316
13154
|
});
|
|
12317
13155
|
|
|
12318
|
-
hub.onActive((active) =>
|
|
13156
|
+
hub.onActive((active) => {
|
|
13157
|
+
onHubActive(active);
|
|
13158
|
+
pushTerminalClaims();
|
|
13159
|
+
});
|
|
13160
|
+
hub.onStatus(pushTerminalClaims);
|
|
12319
13161
|
|
|
12320
13162
|
// session.changed goes out once the switch settles, so arrowing through the list sends one event.
|
|
12321
13163
|
let publishTimer = 0;
|