claude-code-kanban 5.1.1 → 5.2.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-settings.js +21 -0
- package/lib/dispatch.js +11 -4
- package/lib/retention.js +137 -0
- package/lib/session-events.js +30 -7
- package/lib/terminal.js +23 -3
- 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 +14 -7
- 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 +2 -2
- 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 -1
- package/public/app.js +699 -138
- 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 +20 -14
- package/public/style.css +277 -44
- package/server.js +152 -15
- package/skill-guides/dispatch.md +1 -1
package/public/app.js
CHANGED
|
@@ -260,7 +260,7 @@ let lastTasksHash = '';
|
|
|
260
260
|
//#endregion
|
|
261
261
|
|
|
262
262
|
//#region DATA_FETCHING
|
|
263
|
-
async function fetchSessions(includeTasks = true) {
|
|
263
|
+
async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
|
|
264
264
|
try {
|
|
265
265
|
const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
|
|
266
266
|
if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
|
|
@@ -269,7 +269,7 @@ async function fetchSessions(includeTasks = true) {
|
|
|
269
269
|
// The focused session must come back whatever the filters say: renderSession and the
|
|
270
270
|
// info modal both look it up in `sessions` and bail when it is absent, so a session
|
|
271
271
|
// opened from outside the current filter would leave the view on the previous one.
|
|
272
|
-
const includeParam =
|
|
272
|
+
const includeParam = focusId ? `&include=${encodeURIComponent(focusId)}` : '';
|
|
273
273
|
const projectParam =
|
|
274
274
|
filterProject === '__recent__'
|
|
275
275
|
? `&recentHours=${RECENT_PROJECT_HOURS}`
|
|
@@ -1727,6 +1727,8 @@ const ICON_AGENT_ACTIVE =
|
|
|
1727
1727
|
const ICON_TERMINAL =
|
|
1728
1728
|
'<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
1729
|
const TERMINAL_TITLE = 'Running in a terminal here';
|
|
1730
|
+
const ICON_DISPATCHED =
|
|
1731
|
+
'<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
1732
|
const ICON_CHAT =
|
|
1731
1733
|
'<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
1734
|
const TOOL_ICONS = {
|
|
@@ -1974,16 +1976,24 @@ function offloadSessionPin(sessionId) {
|
|
|
1974
1976
|
}).catch(() => {});
|
|
1975
1977
|
}
|
|
1976
1978
|
|
|
1979
|
+
function clearSessionPin(sessionId) {
|
|
1980
|
+
pinnedSessionIds.delete(sessionId);
|
|
1981
|
+
stickySessionIds.delete(sessionId);
|
|
1982
|
+
deferredPinPlacement.delete(sessionId);
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
function unpinSession(sessionId) {
|
|
1986
|
+
clearSessionPin(sessionId);
|
|
1987
|
+
savePinnedSessions();
|
|
1988
|
+
offloadSessionPin(sessionId);
|
|
1989
|
+
renderSessions();
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1977
1992
|
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
|
-
}
|
|
1993
|
+
if (pinnedSessionIds.has(sessionId)) return unpinSession(sessionId);
|
|
1994
|
+
pinnedSessionIds.add(sessionId);
|
|
1995
|
+
if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
|
|
1996
|
+
expandPinnedFor(sessionId);
|
|
1987
1997
|
savePinnedSessions();
|
|
1988
1998
|
offloadSessionPin(sessionId);
|
|
1989
1999
|
renderSessions();
|
|
@@ -1991,9 +2001,7 @@ function toggleSessionPin(sessionId) {
|
|
|
1991
2001
|
|
|
1992
2002
|
function toggleSessionSticky(sessionId) {
|
|
1993
2003
|
if (stickySessionIds.has(sessionId)) {
|
|
1994
|
-
|
|
1995
|
-
pinnedSessionIds.delete(sessionId);
|
|
1996
|
-
deferredPinPlacement.delete(sessionId);
|
|
2004
|
+
clearSessionPin(sessionId);
|
|
1997
2005
|
} else {
|
|
1998
2006
|
pinnedSessionIds.add(sessionId);
|
|
1999
2007
|
stickySessionIds.add(sessionId);
|
|
@@ -2013,9 +2021,7 @@ function isPlacedSticky(id) {
|
|
|
2013
2021
|
|
|
2014
2022
|
function handleSessionPinEvent({ id, state }) {
|
|
2015
2023
|
if (!id) return;
|
|
2016
|
-
|
|
2017
|
-
stickySessionIds.delete(id);
|
|
2018
|
-
deferredPinPlacement.delete(id);
|
|
2024
|
+
clearSessionPin(id);
|
|
2019
2025
|
if (state === 'pinned') pinnedSessionIds.add(id);
|
|
2020
2026
|
if (state === 'sticky') {
|
|
2021
2027
|
pinnedSessionIds.add(id);
|
|
@@ -2456,14 +2462,21 @@ let _manualRefreshing = false;
|
|
|
2456
2462
|
|
|
2457
2463
|
//#region TOAST
|
|
2458
2464
|
// `action` ({label, onClick}) adds a button and keeps the toast up long enough to reach it.
|
|
2459
|
-
function showToast(msg, type, action) {
|
|
2465
|
+
function showToast(msg, type, action, hint) {
|
|
2460
2466
|
const el = document.getElementById('toast');
|
|
2461
2467
|
clearTimeout(_toastTimer);
|
|
2462
2468
|
el.style.transition = 'none';
|
|
2463
2469
|
el.classList.remove('visible', 'toast-success', 'toast-error', 'toast-info', 'has-action');
|
|
2464
2470
|
void el.offsetHeight;
|
|
2465
2471
|
el.style.transition = '';
|
|
2466
|
-
|
|
2472
|
+
if (hint) {
|
|
2473
|
+
const text = document.createElement('div');
|
|
2474
|
+
const hintEl = document.createElement('div');
|
|
2475
|
+
hintEl.className = 'toast-hint';
|
|
2476
|
+
hintEl.textContent = hint;
|
|
2477
|
+
text.append(msg, hintEl);
|
|
2478
|
+
el.replaceChildren(text);
|
|
2479
|
+
} else el.textContent = msg;
|
|
2467
2480
|
const hide = () => el.classList.remove('visible', 'has-action');
|
|
2468
2481
|
if (action) {
|
|
2469
2482
|
const btn = document.createElement('button');
|
|
@@ -3507,15 +3520,6 @@ function renderSessions() {
|
|
|
3507
3520
|
const projectHtml = renderProjectIdentity(session);
|
|
3508
3521
|
|
|
3509
3522
|
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
3523
|
const isTeam = session.isTeam;
|
|
3520
3524
|
const memberCount = session.memberCount || 0;
|
|
3521
3525
|
|
|
@@ -3539,14 +3543,20 @@ function renderSessions() {
|
|
|
3539
3543
|
: '';
|
|
3540
3544
|
const justFinishedClass = isJustFinished(session) ? 'just-finished' : '';
|
|
3541
3545
|
const sid = escAttrJs(session.id);
|
|
3546
|
+
const progressHtml =
|
|
3547
|
+
total > 0
|
|
3548
|
+
? `<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>`
|
|
3549
|
+
: '';
|
|
3550
|
+
const metricsHtml = progressHtml + (showCtx ? renderContextBar(session) : '');
|
|
3542
3551
|
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}
|
|
3552
|
+
<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
3553
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
3545
3554
|
<div class="session-name">${escapeHtml(sessionName)}</div>
|
|
3546
3555
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
3547
3556
|
${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
|
|
3548
|
-
${
|
|
3549
|
-
<div class="session-
|
|
3557
|
+
${metricsHtml ? `<div class="session-metrics">${metricsHtml}</div>` : ''}
|
|
3558
|
+
<div class="session-footer">
|
|
3559
|
+
<div class="session-time">${formatDate(session.modifiedAt)}</div>
|
|
3550
3560
|
<span class="session-indicators">
|
|
3551
3561
|
${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
3562
|
${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 +3568,13 @@ function renderSessions() {
|
|
|
3558
3568
|
${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
|
|
3559
3569
|
${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
3570
|
${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
|
|
3571
|
+
${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
3572
|
${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>` : ''}
|
|
3562
3573
|
${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
|
|
3563
3574
|
${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
|
|
3564
3575
|
${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
|
|
3565
3576
|
</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
3577
|
</div>
|
|
3569
|
-
${showCtx ? renderContextBar(session.contextStatus) : ''}
|
|
3570
|
-
<div class="session-time">${formatDate(session.modifiedAt)}</div>
|
|
3571
3578
|
</button>
|
|
3572
3579
|
`;
|
|
3573
3580
|
};
|
|
@@ -3577,7 +3584,7 @@ function renderSessions() {
|
|
|
3577
3584
|
if (zenMode) {
|
|
3578
3585
|
sessionsList.innerHTML = `${renderSessionCard(zenSession)}
|
|
3579
3586
|
<div class="zen-panel">
|
|
3580
|
-
${renderContextDetail(zenSession
|
|
3587
|
+
${renderContextDetail(zenSession, { tokens: false }) || '<div class="zen-panel-empty">No context data for this session</div>'}
|
|
3581
3588
|
${renderScratchpadRow(zenSession)}
|
|
3582
3589
|
${renderLinkedDocsHtml(zenSession.id)}
|
|
3583
3590
|
${renderArtifactsHtml(zenSession.id)}
|
|
@@ -3591,7 +3598,7 @@ function renderSessions() {
|
|
|
3591
3598
|
}
|
|
3592
3599
|
|
|
3593
3600
|
const groupPinned = store.getItem('groupPinnedSessions') !== 'false';
|
|
3594
|
-
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 :
|
|
3601
|
+
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
|
|
3595
3602
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
3596
3603
|
const countHtml = (arr) => {
|
|
3597
3604
|
const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
@@ -3609,12 +3616,10 @@ function renderSessions() {
|
|
|
3609
3616
|
|
|
3610
3617
|
const renderGroupSessions = (sessions, pinKey) => {
|
|
3611
3618
|
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));
|
|
3619
|
+
const gIdlePinned = sessions.filter(isInPinnedGroup);
|
|
3620
|
+
if (gIdlePinned.length === 0) return sessions.map(renderSessionCard).join('');
|
|
3621
|
+
const gUnpinned = sessions.filter((s) => !isInPinnedGroup(s));
|
|
3616
3622
|
const pinCollapsed = collapsedProjectGroups.has(pinKey);
|
|
3617
|
-
if (gIdlePinned.length === 0) return gUnpinned.map(renderSessionCard).join('');
|
|
3618
3623
|
return (
|
|
3619
3624
|
'<div class="pinned-sub-section">' +
|
|
3620
3625
|
'<div class="pinned-sub-header' +
|
|
@@ -4351,6 +4356,12 @@ function sgTransientGroup(name) {
|
|
|
4351
4356
|
return group;
|
|
4352
4357
|
}
|
|
4353
4358
|
|
|
4359
|
+
function dispatchedTitle({ parent, outcome }) {
|
|
4360
|
+
const starter = parent && sessions.find((s) => s.id === parent);
|
|
4361
|
+
const by = starter ? sessionDisplayName(starter) : 'another session';
|
|
4362
|
+
return `Started by ${by}${outcome ? ` · ${outcome}` : ''}${parent ? ' — click to reveal it' : ''}`;
|
|
4363
|
+
}
|
|
4364
|
+
|
|
4354
4365
|
// A user group with the same name takes the session in, so Keep does not split a group in two.
|
|
4355
4366
|
// With no group of its own, a started session follows its starter into the starter's named group.
|
|
4356
4367
|
function sgDispatchGroupFor(session) {
|
|
@@ -5131,7 +5142,7 @@ function uncollapseFor(session) {
|
|
|
5131
5142
|
}
|
|
5132
5143
|
|
|
5133
5144
|
function pinnedCollapseKeys(session) {
|
|
5134
|
-
if (!
|
|
5145
|
+
if (!isInPinnedGroup(session)) return [];
|
|
5135
5146
|
const group = sgGroupForSession(session);
|
|
5136
5147
|
const host = sgHostOf(group, session);
|
|
5137
5148
|
return [pinKey(session.project || '__ungrouped__'), host && pinKey(host), group && `__pinned_group_${group.id}__`];
|
|
@@ -5507,10 +5518,6 @@ function closeDetailPanel() {
|
|
|
5507
5518
|
taskHighlightDimmed = false;
|
|
5508
5519
|
}
|
|
5509
5520
|
|
|
5510
|
-
let deleteTaskId = null;
|
|
5511
|
-
let deleteSessionId = null;
|
|
5512
|
-
let deleteModalKeyHandler = null;
|
|
5513
|
-
|
|
5514
5521
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5515
5522
|
function showBlockedTaskModal(task) {
|
|
5516
5523
|
const messageDiv = document.getElementById('blocked-task-message');
|
|
@@ -5555,62 +5562,47 @@ function closeBlockedTaskModal() {
|
|
|
5555
5562
|
//#endregion
|
|
5556
5563
|
|
|
5557
5564
|
//#region DELETE_TASK
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
const
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
const modal = document.getElementById('delete-confirm-modal');
|
|
5565
|
+
// Set while #confirm-modal is open; settles the open confirmModal() promise.
|
|
5566
|
+
let closeConfirmModal = null;
|
|
5567
|
+
|
|
5568
|
+
function confirmModal({ title, message, okLabel }) {
|
|
5569
|
+
const modal = document.getElementById('confirm-modal');
|
|
5570
|
+
document.getElementById('confirm-modal-title').textContent = title;
|
|
5571
|
+
document.getElementById('confirm-modal-message').textContent = message;
|
|
5572
|
+
const buttons = [document.getElementById('confirm-cancel-btn'), document.getElementById('confirm-ok-btn')];
|
|
5573
|
+
buttons[1].textContent = okLabel;
|
|
5569
5574
|
modal.classList.add('visible');
|
|
5575
|
+
buttons[1].focus();
|
|
5570
5576
|
|
|
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')) {
|
|
5577
|
+
const onKey = (e) => {
|
|
5578
|
+
if (matchKey(e, 'ArrowLeft', 'ArrowRight')) {
|
|
5580
5579
|
e.preventDefault();
|
|
5581
|
-
|
|
5582
|
-
buttons[focusIdx].focus();
|
|
5583
|
-
} else if (matchKey(e, 'ArrowRight')) {
|
|
5584
|
-
e.preventDefault();
|
|
5585
|
-
focusIdx = 1;
|
|
5586
|
-
buttons[focusIdx].focus();
|
|
5580
|
+
buttons[e.key === 'ArrowLeft' ? 0 : 1].focus();
|
|
5587
5581
|
} else if (e.key === 'Enter') {
|
|
5588
5582
|
e.preventDefault();
|
|
5589
|
-
buttons[
|
|
5583
|
+
(document.activeElement === buttons[0] ? buttons[0] : buttons[1]).click();
|
|
5590
5584
|
}
|
|
5591
5585
|
};
|
|
5592
|
-
document.addEventListener('keydown',
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
document.removeEventListener('keydown', deleteModalKeyHandler);
|
|
5602
|
-
deleteModalKeyHandler = null;
|
|
5603
|
-
}
|
|
5586
|
+
document.addEventListener('keydown', onKey);
|
|
5587
|
+
return new Promise((resolve) => {
|
|
5588
|
+
closeConfirmModal = (ok) => {
|
|
5589
|
+
modal.classList.remove('visible');
|
|
5590
|
+
document.removeEventListener('keydown', onKey);
|
|
5591
|
+
closeConfirmModal = null;
|
|
5592
|
+
resolve(ok);
|
|
5593
|
+
};
|
|
5594
|
+
});
|
|
5604
5595
|
}
|
|
5605
5596
|
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
if (!
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5597
|
+
async function deleteTask(taskId, sessionId) {
|
|
5598
|
+
const task = currentTasks.find((t) => t.id === taskId);
|
|
5599
|
+
if (!task) return;
|
|
5600
|
+
const ok = await confirmModal({
|
|
5601
|
+
title: 'Delete Task',
|
|
5602
|
+
message: `Delete task "${task.subject}"? This cannot be undone.`,
|
|
5603
|
+
okLabel: 'Delete',
|
|
5604
|
+
});
|
|
5605
|
+
if (!ok) return;
|
|
5614
5606
|
|
|
5615
5607
|
try {
|
|
5616
5608
|
const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
|
|
@@ -5696,7 +5688,7 @@ const SHORTCUT_TABS = [
|
|
|
5696
5688
|
{ keys: ['.'], label: 'Pin / unpin' },
|
|
5697
5689
|
{ keys: ['>'], label: 'Toggle sticky' },
|
|
5698
5690
|
{ keys: ['Shift', 'F10'], combo: true, label: 'Move to group (or Menu key)' },
|
|
5699
|
-
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session' },
|
|
5691
|
+
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
|
|
5700
5692
|
{ keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
5701
5693
|
{ keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
|
|
5702
5694
|
{ keys: ['↑', '↓'], label: 'Previous / next message in detail' },
|
|
@@ -6177,8 +6169,7 @@ async function _storageViewSession(id) {
|
|
|
6177
6169
|
}
|
|
6178
6170
|
|
|
6179
6171
|
function _storageUnpinSession(id) {
|
|
6180
|
-
|
|
6181
|
-
stickySessionIds.delete(id);
|
|
6172
|
+
clearSessionPin(id);
|
|
6182
6173
|
savePinnedSessions();
|
|
6183
6174
|
renderSessions();
|
|
6184
6175
|
_renderStorageTab();
|
|
@@ -6463,6 +6454,47 @@ function matchKey(e, ...keys) {
|
|
|
6463
6454
|
return keys.some((k) => e.key === k || e.code === k);
|
|
6464
6455
|
}
|
|
6465
6456
|
|
|
6457
|
+
function dismissSessionFromList(sid) {
|
|
6458
|
+
const prevIdx = selectedSessionIdx;
|
|
6459
|
+
if (!setSessionDismissed(sid, true)) return;
|
|
6460
|
+
const newItems = getNavigableItems();
|
|
6461
|
+
if (isAnyPinned(sid)) {
|
|
6462
|
+
const pinnedIdx = newItems.findIndex((el) => el.dataset?.sessionId === sid);
|
|
6463
|
+
if (pinnedIdx >= 0) selectSessionByIndex(pinnedIdx, newItems);
|
|
6464
|
+
return;
|
|
6465
|
+
}
|
|
6466
|
+
const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
|
|
6467
|
+
// If the dismissed session is currently open, navigate to the previous one
|
|
6468
|
+
if (currentSessionId === sid || selectedSessionId === sid) {
|
|
6469
|
+
selectedSessionId = null;
|
|
6470
|
+
if (targetIdx >= 0) {
|
|
6471
|
+
const targetSid = newItems[targetIdx]?.dataset?.sessionId;
|
|
6472
|
+
if (targetSid) {
|
|
6473
|
+
fetchTasks(targetSid).then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
|
|
6474
|
+
} else {
|
|
6475
|
+
showNoSession();
|
|
6476
|
+
selectSessionByIndex(targetIdx, getNavigableItems());
|
|
6477
|
+
}
|
|
6478
|
+
} else {
|
|
6479
|
+
showNoSession();
|
|
6480
|
+
}
|
|
6481
|
+
} else if (targetIdx >= 0) {
|
|
6482
|
+
selectSessionByIndex(targetIdx, newItems);
|
|
6483
|
+
}
|
|
6484
|
+
}
|
|
6485
|
+
|
|
6486
|
+
async function dismissStickySession(sid) {
|
|
6487
|
+
const session = sessions.find((s) => s.id === sid);
|
|
6488
|
+
const ok = await confirmModal({
|
|
6489
|
+
title: 'Remove Sticky Pin',
|
|
6490
|
+
message: `"${session ? sessionDisplayName(session) : sid.slice(0, 8)}" has a sticky pin. Remove the pin and dismiss the session?`,
|
|
6491
|
+
okLabel: 'Unpin and dismiss',
|
|
6492
|
+
});
|
|
6493
|
+
if (!ok) return;
|
|
6494
|
+
unpinSession(sid);
|
|
6495
|
+
dismissSessionFromList(sid);
|
|
6496
|
+
}
|
|
6497
|
+
|
|
6466
6498
|
const MODAL_ESC_PRIORITY = [
|
|
6467
6499
|
'preview-modal',
|
|
6468
6500
|
'msg-detail-modal',
|
|
@@ -6488,6 +6520,7 @@ const MODAL_CLOSERS = {
|
|
|
6488
6520
|
'project-picker-modal': () => closeProjectPicker(),
|
|
6489
6521
|
'terminal-manager-modal': () => closeTerminalManager(),
|
|
6490
6522
|
'new-session-modal': () => closeNewSession(),
|
|
6523
|
+
'confirm-modal': () => closeConfirmModal?.(false),
|
|
6491
6524
|
};
|
|
6492
6525
|
|
|
6493
6526
|
document.addEventListener('keydown', (e) => {
|
|
@@ -6795,27 +6828,8 @@ document.addEventListener('keydown', (e) => {
|
|
|
6795
6828
|
}
|
|
6796
6829
|
if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
|
|
6797
6830
|
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
|
-
}
|
|
6831
|
+
if (!contextSid) return;
|
|
6832
|
+
(stickySessionIds.has(contextSid) ? dismissStickySession : dismissSessionFromList)(contextSid);
|
|
6819
6833
|
return;
|
|
6820
6834
|
}
|
|
6821
6835
|
if (e.code === 'KeyC' && e.shiftKey && !e.altKey && !e.metaKey) {
|
|
@@ -6867,6 +6881,484 @@ document.addEventListener('keydown', (e) => {
|
|
|
6867
6881
|
|
|
6868
6882
|
//#endregion
|
|
6869
6883
|
|
|
6884
|
+
//#region REVIEW
|
|
6885
|
+
// Comments on rendered content, sent to a session as one batch. Drafts outlive the view in
|
|
6886
|
+
// this tab, keyed by session and source, so closing a preview loses nothing. Highlights use
|
|
6887
|
+
// the CSS Custom Highlight API, which marks ranges without touching the DOM the renderer
|
|
6888
|
+
// built; a draft restored on reopen has no mark and shows in the panel only.
|
|
6889
|
+
//
|
|
6890
|
+
// An HTML preview is an iframe on an opaque origin, so its selection is out of reach from
|
|
6891
|
+
// here. `reviewBridge` runs inside the frame and reports each selection over postMessage;
|
|
6892
|
+
// the frame keeps its own ranges and marks them by id. A page whose CSP forbids inline
|
|
6893
|
+
// scripts gets no comments.
|
|
6894
|
+
const reviewDrafts = new Map();
|
|
6895
|
+
const reviewHighlight = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
|
|
6896
|
+
const reviewHotHighlight = reviewHighlight ? new Highlight() : null;
|
|
6897
|
+
if (reviewHighlight) {
|
|
6898
|
+
CSS.highlights.set('review', reviewHighlight);
|
|
6899
|
+
CSS.highlights.set('review-hot', reviewHotHighlight);
|
|
6900
|
+
}
|
|
6901
|
+
const REVIEW_MSG = 'cck-review:';
|
|
6902
|
+
let activeReview = null;
|
|
6903
|
+
let reviewMarkSeq = 0;
|
|
6904
|
+
let reviewPopMode = null;
|
|
6905
|
+
let reviewHoverAt = null;
|
|
6906
|
+
let reviewHoverFrame = 0;
|
|
6907
|
+
|
|
6908
|
+
function reviewKey(sessionId, source) {
|
|
6909
|
+
return `${sessionId}|${source.kind}|${source.path ? canonicalPath(source.path) : source.label}`;
|
|
6910
|
+
}
|
|
6911
|
+
|
|
6912
|
+
function reviewItems() {
|
|
6913
|
+
return (activeReview && reviewDrafts.get(activeReview.key)) || [];
|
|
6914
|
+
}
|
|
6915
|
+
|
|
6916
|
+
// `context(range)` adds view-specific citation fields to a comment on the host DOM;
|
|
6917
|
+
// `onSent()` runs after a batch is delivered.
|
|
6918
|
+
function mountReview({ contentEl, panelEl, hostEl, source, frame, context, onSent, sessionId = currentSessionId }) {
|
|
6919
|
+
unmountReview();
|
|
6920
|
+
if (!sessionId) return;
|
|
6921
|
+
const ctl = new AbortController();
|
|
6922
|
+
const { signal } = ctl;
|
|
6923
|
+
const key = reviewKey(sessionId, source);
|
|
6924
|
+
activeReview = {
|
|
6925
|
+
contentEl,
|
|
6926
|
+
panelEl,
|
|
6927
|
+
hostEl,
|
|
6928
|
+
source,
|
|
6929
|
+
frame,
|
|
6930
|
+
context,
|
|
6931
|
+
onSent,
|
|
6932
|
+
sessionId,
|
|
6933
|
+
key,
|
|
6934
|
+
ctl,
|
|
6935
|
+
hot: null,
|
|
6936
|
+
};
|
|
6937
|
+
if (!frame) {
|
|
6938
|
+
contentEl.addEventListener('mouseup', () => setTimeout(offerReviewComment, 0), { signal });
|
|
6939
|
+
contentEl.addEventListener('mousemove', (e) => queueReviewHover(e.clientX, e.clientY), { signal });
|
|
6940
|
+
}
|
|
6941
|
+
window.addEventListener('message', onReviewFrameMessage, { signal });
|
|
6942
|
+
document.addEventListener('mousedown', closeReviewPop, { signal });
|
|
6943
|
+
document.addEventListener('scroll', closeReviewOffer, { capture: true, signal });
|
|
6944
|
+
panelEl.addEventListener('click', onReviewPanelClick, { signal });
|
|
6945
|
+
panelEl.addEventListener('mouseover', onReviewPanelHover, { signal });
|
|
6946
|
+
panelEl.addEventListener('mouseleave', () => setReviewHot(null), { signal });
|
|
6947
|
+
renderReviewPanel();
|
|
6948
|
+
}
|
|
6949
|
+
|
|
6950
|
+
// With `ownPanel`, unmounts only the review that panel holds, so a modal under another one keeps it.
|
|
6951
|
+
function unmountReview(ownPanel) {
|
|
6952
|
+
if (ownPanel && activeReview?.panelEl !== ownPanel) return;
|
|
6953
|
+
closeReviewPop();
|
|
6954
|
+
if (!activeReview) return;
|
|
6955
|
+
const { ctl, panelEl, hostEl } = activeReview;
|
|
6956
|
+
ctl.abort();
|
|
6957
|
+
for (const c of reviewItems()) {
|
|
6958
|
+
c.range = null;
|
|
6959
|
+
c.frameId = 0;
|
|
6960
|
+
}
|
|
6961
|
+
panelEl.replaceChildren();
|
|
6962
|
+
panelEl.hidden = true;
|
|
6963
|
+
hostEl?.classList.remove('has-review');
|
|
6964
|
+
activeReview = null;
|
|
6965
|
+
reviewHighlight?.clear();
|
|
6966
|
+
reviewHotHighlight?.clear();
|
|
6967
|
+
}
|
|
6968
|
+
|
|
6969
|
+
// The three helpers below are also handed to `reviewBridge` as source text, so each must use
|
|
6970
|
+
// only its arguments and the globals of whichever document it runs in.
|
|
6971
|
+
function reviewTextBefore(root, container, offset) {
|
|
6972
|
+
const before = root.ownerDocument.createRange();
|
|
6973
|
+
before.setStart(root, 0);
|
|
6974
|
+
before.setEnd(container, offset);
|
|
6975
|
+
return before.toString();
|
|
6976
|
+
}
|
|
6977
|
+
|
|
6978
|
+
function reviewHeadingBefore(root, node) {
|
|
6979
|
+
let found = null;
|
|
6980
|
+
for (const h of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
|
6981
|
+
if (h.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING || h.contains(node)) found = h;
|
|
6982
|
+
else break;
|
|
6983
|
+
}
|
|
6984
|
+
return found?.textContent.trim().slice(0, 200) || undefined;
|
|
6985
|
+
}
|
|
6986
|
+
|
|
6987
|
+
function reviewRangeAt(x, y, entries) {
|
|
6988
|
+
const at = document.caretPositionFromPoint?.(x, y);
|
|
6989
|
+
if (!at?.offsetNode) return null;
|
|
6990
|
+
for (const [key, range] of entries) if (range?.isPointInRange(at.offsetNode, at.offset)) return key;
|
|
6991
|
+
return null;
|
|
6992
|
+
}
|
|
6993
|
+
|
|
6994
|
+
function queueReviewHover(x, y) {
|
|
6995
|
+
reviewHoverAt = [x, y];
|
|
6996
|
+
if (reviewHoverFrame) return;
|
|
6997
|
+
reviewHoverFrame = requestAnimationFrame(() => {
|
|
6998
|
+
reviewHoverFrame = 0;
|
|
6999
|
+
const marked = reviewItems().filter((c) => c.range);
|
|
7000
|
+
if (!marked.length) return;
|
|
7001
|
+
const [hx, hy] = reviewHoverAt;
|
|
7002
|
+
setReviewHot(
|
|
7003
|
+
reviewRangeAt(
|
|
7004
|
+
hx,
|
|
7005
|
+
hy,
|
|
7006
|
+
marked.map((c) => [c, c.range]),
|
|
7007
|
+
),
|
|
7008
|
+
true,
|
|
7009
|
+
);
|
|
7010
|
+
});
|
|
7011
|
+
}
|
|
7012
|
+
|
|
7013
|
+
function setReviewHot(c, fromText) {
|
|
7014
|
+
if (!activeReview || activeReview.hot === c) return;
|
|
7015
|
+
activeReview.hot = c;
|
|
7016
|
+
reviewHotHighlight?.clear();
|
|
7017
|
+
if (c?.range) reviewHotHighlight?.add(c.range);
|
|
7018
|
+
if (activeReview.frame) postToReviewFrame('hot', c?.frameId || 0);
|
|
7019
|
+
const { panelEl } = activeReview;
|
|
7020
|
+
panelEl.querySelector('.review-card.hot')?.classList.remove('hot');
|
|
7021
|
+
const i = reviewItems().indexOf(c);
|
|
7022
|
+
const card = i >= 0 ? panelEl.querySelector(`.review-card[data-i="${i}"]`) : null;
|
|
7023
|
+
card?.classList.add('hot');
|
|
7024
|
+
if (fromText) card?.scrollIntoView({ block: 'nearest' });
|
|
7025
|
+
}
|
|
7026
|
+
|
|
7027
|
+
function reviewSelection() {
|
|
7028
|
+
const sel = window.getSelection();
|
|
7029
|
+
if (!activeReview || !sel || sel.isCollapsed || !sel.rangeCount) return null;
|
|
7030
|
+
const range = sel.getRangeAt(0);
|
|
7031
|
+
if (!activeReview.contentEl.contains(range.commonAncestorContainer)) return null;
|
|
7032
|
+
const quote = sel.toString().trim();
|
|
7033
|
+
if (!quote) return null;
|
|
7034
|
+
return { quote, range: range.cloneRange(), rect: range.getBoundingClientRect() };
|
|
7035
|
+
}
|
|
7036
|
+
|
|
7037
|
+
function onReviewFrameMessage(e) {
|
|
7038
|
+
const frameWin = activeReview?.frame?.contentWindow;
|
|
7039
|
+
const type =
|
|
7040
|
+
typeof e.data?.type === 'string' && e.data.type.startsWith(REVIEW_MSG) && e.data.type.slice(REVIEW_MSG.length);
|
|
7041
|
+
if (!frameWin || e.source !== frameWin || !type) return;
|
|
7042
|
+
if (type === 'clear') return closeReviewOffer();
|
|
7043
|
+
if (type === 'hover') {
|
|
7044
|
+
return setReviewHot((e.data.id && reviewItems().find((c) => c.frameId === e.data.id)) || null, true);
|
|
7045
|
+
}
|
|
7046
|
+
if (type !== 'select' || typeof e.data.quote !== 'string' || !e.data.quote.trim()) return;
|
|
7047
|
+
const box = activeReview.frame.getBoundingClientRect();
|
|
7048
|
+
const r = e.data.rect || {};
|
|
7049
|
+
const rect = { left: box.left + (r.left || 0), top: box.top + (r.top || 0), bottom: box.top + (r.bottom || 0) };
|
|
7050
|
+
const c = e.data.context || {};
|
|
7051
|
+
const context = { heading: c.heading, element: c.element, selector: c.selector };
|
|
7052
|
+
const pos = Number.isFinite(e.data.pos) ? e.data.pos : 0;
|
|
7053
|
+
showReviewOffer({ quote: e.data.quote.trim(), context, pos, rect, inFrame: true });
|
|
7054
|
+
}
|
|
7055
|
+
|
|
7056
|
+
function postToReviewFrame(type, id) {
|
|
7057
|
+
activeReview?.frame?.contentWindow?.postMessage({ type: REVIEW_MSG + type, id }, '*');
|
|
7058
|
+
}
|
|
7059
|
+
|
|
7060
|
+
// Runs inside the preview iframe as source text, so it must not close over anything outside
|
|
7061
|
+
// its own body and arguments.
|
|
7062
|
+
function reviewBridge(textBefore, headingBefore, rangeAt) {
|
|
7063
|
+
const P = 'cck-review:';
|
|
7064
|
+
const marks = new Map();
|
|
7065
|
+
let pending = null;
|
|
7066
|
+
let hotId = 0;
|
|
7067
|
+
let hoverAt = null;
|
|
7068
|
+
let hoverFrame = 0;
|
|
7069
|
+
const hl = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
|
|
7070
|
+
const hot = hl ? new Highlight() : null;
|
|
7071
|
+
if (hl) {
|
|
7072
|
+
CSS.highlights.set('cck-review', hl);
|
|
7073
|
+
CSS.highlights.set('cck-review-hot', hot);
|
|
7074
|
+
const style = document.createElement('style');
|
|
7075
|
+
style.textContent =
|
|
7076
|
+
'::highlight(cck-review){background-color:rgba(240,180,41,.35)}::highlight(cck-review-hot){background-color:rgba(240,180,41,.7)}';
|
|
7077
|
+
(document.head || document.documentElement).append(style);
|
|
7078
|
+
}
|
|
7079
|
+
const send = (msg) => parent.postMessage({ ...msg, type: P + msg.type }, '*');
|
|
7080
|
+
const describe = (node) => {
|
|
7081
|
+
const el = node.nodeType === 1 ? node : node.parentElement;
|
|
7082
|
+
if (!el) return {};
|
|
7083
|
+
const cls = [...el.classList].slice(0, 3);
|
|
7084
|
+
const attrs = [el.tagName.toLowerCase()];
|
|
7085
|
+
if (el.id) attrs.push(`id=${JSON.stringify(el.id)}`);
|
|
7086
|
+
if (cls.length) attrs.push(`class=${JSON.stringify(cls.join(' '))}`);
|
|
7087
|
+
const element = `<${attrs.join(' ')}>`;
|
|
7088
|
+
const parts = [];
|
|
7089
|
+
for (let n = el; n && n !== document.body && n.nodeType === 1; n = n.parentElement) {
|
|
7090
|
+
if (n.id) {
|
|
7091
|
+
parts.unshift(`#${n.id}`);
|
|
7092
|
+
break;
|
|
7093
|
+
}
|
|
7094
|
+
const tag = n.tagName.toLowerCase();
|
|
7095
|
+
const same = n.parentElement ? [...n.parentElement.children].filter((s) => s.tagName === n.tagName) : [];
|
|
7096
|
+
parts.unshift(same.length > 1 ? `${tag}:nth-of-type(${same.indexOf(n) + 1})` : tag);
|
|
7097
|
+
}
|
|
7098
|
+
return { element, selector: parts.join(' > '), heading: headingBefore(document, el) };
|
|
7099
|
+
};
|
|
7100
|
+
document.addEventListener('mouseup', () =>
|
|
7101
|
+
setTimeout(() => {
|
|
7102
|
+
const sel = getSelection();
|
|
7103
|
+
const quote = sel && !sel.isCollapsed && sel.rangeCount ? sel.toString().trim() : '';
|
|
7104
|
+
if (!quote) {
|
|
7105
|
+
pending = null;
|
|
7106
|
+
return;
|
|
7107
|
+
}
|
|
7108
|
+
pending = sel.getRangeAt(0).cloneRange();
|
|
7109
|
+
const r = pending.getBoundingClientRect();
|
|
7110
|
+
send({
|
|
7111
|
+
type: 'select',
|
|
7112
|
+
quote,
|
|
7113
|
+
pos: textBefore(document.body, pending.startContainer, pending.startOffset).length,
|
|
7114
|
+
rect: { left: r.left, top: r.top, bottom: r.bottom },
|
|
7115
|
+
context: describe(pending.commonAncestorContainer),
|
|
7116
|
+
});
|
|
7117
|
+
}, 0),
|
|
7118
|
+
);
|
|
7119
|
+
document.addEventListener('mousemove', (e) => {
|
|
7120
|
+
if (!marks.size && !hotId) return;
|
|
7121
|
+
hoverAt = [e.clientX, e.clientY];
|
|
7122
|
+
if (hoverFrame) return;
|
|
7123
|
+
hoverFrame = requestAnimationFrame(() => {
|
|
7124
|
+
hoverFrame = 0;
|
|
7125
|
+
const id = rangeAt(hoverAt[0], hoverAt[1], marks) || 0;
|
|
7126
|
+
if (id !== hotId) {
|
|
7127
|
+
hotId = id;
|
|
7128
|
+
send({ type: 'hover', id });
|
|
7129
|
+
}
|
|
7130
|
+
});
|
|
7131
|
+
});
|
|
7132
|
+
const clear = () => {
|
|
7133
|
+
if (pending) send({ type: 'clear' });
|
|
7134
|
+
};
|
|
7135
|
+
document.addEventListener('mousedown', clear);
|
|
7136
|
+
document.addEventListener('scroll', clear, true);
|
|
7137
|
+
addEventListener('message', (e) => {
|
|
7138
|
+
if (e.source !== parent || typeof e.data?.type !== 'string' || !e.data.type.startsWith(P)) return;
|
|
7139
|
+
const type = e.data.type.slice(P.length);
|
|
7140
|
+
const range = marks.get(e.data.id);
|
|
7141
|
+
if (type === 'mark' && pending) {
|
|
7142
|
+
marks.set(e.data.id, pending);
|
|
7143
|
+
hl?.add(pending);
|
|
7144
|
+
pending = null;
|
|
7145
|
+
getSelection().removeAllRanges();
|
|
7146
|
+
} else if (type === 'hot') {
|
|
7147
|
+
hot?.clear();
|
|
7148
|
+
if (range) hot?.add(range);
|
|
7149
|
+
} else if (type === 'unmark' && range) {
|
|
7150
|
+
hl?.delete(range);
|
|
7151
|
+
hot?.delete(range);
|
|
7152
|
+
marks.delete(e.data.id);
|
|
7153
|
+
} else if (type === 'reveal' && range) {
|
|
7154
|
+
range.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
7155
|
+
}
|
|
7156
|
+
});
|
|
7157
|
+
}
|
|
7158
|
+
|
|
7159
|
+
const REVIEW_BRIDGE_TAG = `<script>(${reviewBridge})(${reviewTextBefore}, ${reviewHeadingBefore}, ${reviewRangeAt});</script>`;
|
|
7160
|
+
|
|
7161
|
+
function reviewPopEl() {
|
|
7162
|
+
let el = document.getElementById('review-pop');
|
|
7163
|
+
if (!el) {
|
|
7164
|
+
el = document.createElement('div');
|
|
7165
|
+
el.id = 'review-pop';
|
|
7166
|
+
el.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
7167
|
+
el.addEventListener('click', (e) => e.stopPropagation());
|
|
7168
|
+
document.body.append(el);
|
|
7169
|
+
}
|
|
7170
|
+
return el;
|
|
7171
|
+
}
|
|
7172
|
+
|
|
7173
|
+
function placeReviewPop(el, r) {
|
|
7174
|
+
el.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - el.offsetWidth - 8))}px`;
|
|
7175
|
+
const below = r.bottom + 6;
|
|
7176
|
+
el.style.top = `${below + el.offsetHeight > window.innerHeight ? Math.max(8, r.top - el.offsetHeight - 6) : below}px`;
|
|
7177
|
+
}
|
|
7178
|
+
|
|
7179
|
+
function closeReviewPop() {
|
|
7180
|
+
if (!reviewPopMode) return;
|
|
7181
|
+
reviewPopMode = null;
|
|
7182
|
+
const el = reviewPopEl();
|
|
7183
|
+
el.className = '';
|
|
7184
|
+
el.replaceChildren();
|
|
7185
|
+
}
|
|
7186
|
+
|
|
7187
|
+
function closeReviewOffer() {
|
|
7188
|
+
if (reviewPopMode === 'offer') closeReviewPop();
|
|
7189
|
+
}
|
|
7190
|
+
|
|
7191
|
+
function offerReviewComment() {
|
|
7192
|
+
const picked = reviewSelection();
|
|
7193
|
+
if (picked) showReviewOffer(picked);
|
|
7194
|
+
else if (reviewPopMode !== 'editor') closeReviewPop();
|
|
7195
|
+
}
|
|
7196
|
+
|
|
7197
|
+
function showReviewOffer(picked) {
|
|
7198
|
+
const el = reviewPopEl();
|
|
7199
|
+
reviewPopMode = 'offer';
|
|
7200
|
+
el.className = 'visible offer';
|
|
7201
|
+
el.innerHTML = `<button type="button" class="review-offer-btn">Comment</button>`;
|
|
7202
|
+
el.firstElementChild.addEventListener('click', () => openReviewEditor(picked));
|
|
7203
|
+
placeReviewPop(el, picked.rect);
|
|
7204
|
+
}
|
|
7205
|
+
|
|
7206
|
+
function openReviewEditor(picked) {
|
|
7207
|
+
const el = reviewPopEl();
|
|
7208
|
+
reviewPopMode = 'editor';
|
|
7209
|
+
el.className = 'visible editor';
|
|
7210
|
+
el.innerHTML = `<div class="review-pop-quote">${escapeHtml(picked.quote.slice(0, 200))}</div>
|
|
7211
|
+
<textarea rows="3" placeholder="Comment (Ctrl+Enter to add)"></textarea>
|
|
7212
|
+
<div class="review-pop-actions"><button type="button" data-act="cancel">Cancel</button><button type="button" data-act="add" class="primary">Add</button></div>`;
|
|
7213
|
+
const ta = el.querySelector('textarea');
|
|
7214
|
+
const add = () => {
|
|
7215
|
+
const comment = ta.value.trim();
|
|
7216
|
+
if (!comment) return ta.focus();
|
|
7217
|
+
addReviewComment(picked, comment);
|
|
7218
|
+
};
|
|
7219
|
+
ta.addEventListener('keydown', (e) => {
|
|
7220
|
+
e.stopPropagation();
|
|
7221
|
+
if (e.key === 'Escape') closeReviewPop();
|
|
7222
|
+
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) add();
|
|
7223
|
+
});
|
|
7224
|
+
el.querySelector('[data-act="cancel"]').addEventListener('click', closeReviewPop);
|
|
7225
|
+
el.querySelector('[data-act="add"]').addEventListener('click', add);
|
|
7226
|
+
placeReviewPop(el, picked.rect);
|
|
7227
|
+
ta.focus();
|
|
7228
|
+
}
|
|
7229
|
+
|
|
7230
|
+
function addReviewComment(picked, comment) {
|
|
7231
|
+
if (!activeReview) return;
|
|
7232
|
+
const { key, contentEl } = activeReview;
|
|
7233
|
+
let item;
|
|
7234
|
+
if (picked.inFrame) {
|
|
7235
|
+
item = {
|
|
7236
|
+
quote: picked.quote,
|
|
7237
|
+
comment,
|
|
7238
|
+
context: picked.context,
|
|
7239
|
+
pos: picked.pos,
|
|
7240
|
+
range: null,
|
|
7241
|
+
frameId: ++reviewMarkSeq,
|
|
7242
|
+
};
|
|
7243
|
+
postToReviewFrame('mark', item.frameId);
|
|
7244
|
+
} else {
|
|
7245
|
+
const { range } = picked;
|
|
7246
|
+
const context = {
|
|
7247
|
+
heading: reviewHeadingBefore(contentEl, range.startContainer),
|
|
7248
|
+
...activeReview.context?.(range),
|
|
7249
|
+
};
|
|
7250
|
+
const pos = reviewTextBefore(contentEl, range.startContainer, range.startOffset).length;
|
|
7251
|
+
item = { quote: picked.quote, comment, context, pos, range, frameId: 0 };
|
|
7252
|
+
reviewHighlight?.add(range);
|
|
7253
|
+
window.getSelection()?.removeAllRanges();
|
|
7254
|
+
}
|
|
7255
|
+
if (!reviewDrafts.has(key)) reviewDrafts.set(key, []);
|
|
7256
|
+
const items = reviewDrafts.get(key);
|
|
7257
|
+
items.push(item);
|
|
7258
|
+
items.sort((a, b) => a.pos - b.pos);
|
|
7259
|
+
closeReviewPop();
|
|
7260
|
+
renderReviewPanel();
|
|
7261
|
+
}
|
|
7262
|
+
|
|
7263
|
+
function unmarkReviewItem(c) {
|
|
7264
|
+
if (c.range) reviewHighlight?.delete(c.range);
|
|
7265
|
+
else if (c.frameId) postToReviewFrame('unmark', c.frameId);
|
|
7266
|
+
}
|
|
7267
|
+
|
|
7268
|
+
function revealReviewItem(c) {
|
|
7269
|
+
if (c.range) c.range.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
7270
|
+
else if (c.frameId) postToReviewFrame('reveal', c.frameId);
|
|
7271
|
+
}
|
|
7272
|
+
|
|
7273
|
+
function removeReviewComment(c) {
|
|
7274
|
+
const items = reviewItems();
|
|
7275
|
+
const i = items.indexOf(c);
|
|
7276
|
+
if (i < 0) return;
|
|
7277
|
+
items.splice(i, 1);
|
|
7278
|
+
unmarkReviewItem(c);
|
|
7279
|
+
if (activeReview.hot === c) setReviewHot(null);
|
|
7280
|
+
if (!items.length) reviewDrafts.delete(activeReview.key);
|
|
7281
|
+
renderReviewPanel();
|
|
7282
|
+
}
|
|
7283
|
+
|
|
7284
|
+
function clearReviewDrafts(key = activeReview?.key) {
|
|
7285
|
+
for (const c of reviewDrafts.get(key) || []) unmarkReviewItem(c);
|
|
7286
|
+
reviewDrafts.delete(key);
|
|
7287
|
+
setReviewHot(null);
|
|
7288
|
+
renderReviewPanel();
|
|
7289
|
+
}
|
|
7290
|
+
|
|
7291
|
+
function onReviewPanelClick(e) {
|
|
7292
|
+
const act = e.target.closest('[data-act]')?.dataset.act;
|
|
7293
|
+
if (act === 'send') return sendReview();
|
|
7294
|
+
if (act === 'discard') return clearReviewDrafts();
|
|
7295
|
+
const card = e.target.closest('.review-card');
|
|
7296
|
+
const c = card && reviewItems()[Number(card.dataset.i)];
|
|
7297
|
+
if (!c) return;
|
|
7298
|
+
if (e.target.closest('.review-remove')) removeReviewComment(c);
|
|
7299
|
+
else revealReviewItem(c);
|
|
7300
|
+
}
|
|
7301
|
+
|
|
7302
|
+
function onReviewPanelHover(e) {
|
|
7303
|
+
const card = e.target.closest('.review-card');
|
|
7304
|
+
setReviewHot((card && reviewItems()[Number(card.dataset.i)]) || null);
|
|
7305
|
+
}
|
|
7306
|
+
|
|
7307
|
+
function renderReviewPanel() {
|
|
7308
|
+
if (!activeReview) return;
|
|
7309
|
+
const { panelEl, hostEl } = activeReview;
|
|
7310
|
+
const items = reviewItems();
|
|
7311
|
+
panelEl.hidden = !items.length;
|
|
7312
|
+
hostEl?.classList.toggle('has-review', items.length > 0);
|
|
7313
|
+
if (!items.length) return panelEl.replaceChildren();
|
|
7314
|
+
panelEl.innerHTML = `<div class="review-panel-head">
|
|
7315
|
+
<span>${items.length} comment${items.length > 1 ? 's' : ''}</span>
|
|
7316
|
+
<button type="button" data-act="discard" class="review-discard">Discard</button>
|
|
7317
|
+
<button type="button" data-act="send" class="primary">Send</button>
|
|
7318
|
+
</div>
|
|
7319
|
+
<ol class="review-panel-list">${items
|
|
7320
|
+
.map((c, i) => {
|
|
7321
|
+
return `<li class="review-card${c === activeReview.hot ? ' hot' : ''}" data-i="${i}" title="Show in the document">
|
|
7322
|
+
<button type="button" class="review-remove" title="Remove" aria-label="Remove">×</button>
|
|
7323
|
+
<div class="review-card-quote">${escapeHtml(c.quote.slice(0, 300))}</div>
|
|
7324
|
+
<div class="review-card-comment">${escapeHtml(c.comment)}</div></li>`;
|
|
7325
|
+
})
|
|
7326
|
+
.join('')}</ol>`;
|
|
7327
|
+
}
|
|
7328
|
+
|
|
7329
|
+
async function sendReview() {
|
|
7330
|
+
if (!activeReview) return;
|
|
7331
|
+
const { sessionId, source, key, onSent } = activeReview;
|
|
7332
|
+
const items = reviewItems();
|
|
7333
|
+
if (!items.length) return;
|
|
7334
|
+
try {
|
|
7335
|
+
const res = await terminalFetch(`/api/sessions/${encodeURIComponent(sessionId)}/review`, 'POST', {
|
|
7336
|
+
source,
|
|
7337
|
+
comments: items.map(({ quote, comment, context }) => ({ quote, comment, ...context })),
|
|
7338
|
+
});
|
|
7339
|
+
const data = await res.json();
|
|
7340
|
+
if (!res.ok) return showToast(data.error || 'Review failed', 'error');
|
|
7341
|
+
if (data.delivered === 'doorbell') showToast('Sent to session', 'success');
|
|
7342
|
+
else if (data.delivered === 'terminal') showToast('Pasted into the terminal: press Enter there to send', 'info');
|
|
7343
|
+
else {
|
|
7344
|
+
const { markdown } = data;
|
|
7345
|
+
const copy = () => navigator.clipboard.writeText(markdown).catch(() => {});
|
|
7346
|
+
await copy();
|
|
7347
|
+
showToast(
|
|
7348
|
+
'Review copied. Paste it into the session.',
|
|
7349
|
+
'info',
|
|
7350
|
+
{ label: 'Copy again', onClick: copy },
|
|
7351
|
+
'Run /claude-code-kanban:follow there to send reviews directly.',
|
|
7352
|
+
);
|
|
7353
|
+
}
|
|
7354
|
+
clearReviewDrafts(key);
|
|
7355
|
+
if (activeReview?.key === key) onSent?.();
|
|
7356
|
+
} catch {
|
|
7357
|
+
showToast('Failed to send the review', 'error');
|
|
7358
|
+
}
|
|
7359
|
+
}
|
|
7360
|
+
//#endregion
|
|
7361
|
+
|
|
6870
7362
|
//#region MARKDOWN_PREVIEW
|
|
6871
7363
|
const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
|
|
6872
7364
|
let currentPreviewPath = null;
|
|
@@ -6984,7 +7476,9 @@ function renderHtmlPreview(bodyEl, content) {
|
|
|
6984
7476
|
frame.className = 'preview-html-frame';
|
|
6985
7477
|
frame.setAttribute('sandbox', 'allow-scripts allow-popups');
|
|
6986
7478
|
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
6987
|
-
|
|
7479
|
+
// After the document's end the parser still adds the script to <body>, and it runs after
|
|
7480
|
+
// the page's own scripts.
|
|
7481
|
+
frame.srcdoc = content + REVIEW_BRIDGE_TAG;
|
|
6988
7482
|
bodyEl.appendChild(frame);
|
|
6989
7483
|
}
|
|
6990
7484
|
|
|
@@ -7048,6 +7542,22 @@ function openPreviewModal(filePath, content, kind) {
|
|
|
7048
7542
|
document.getElementById('preview-modal-meta').textContent = filePath;
|
|
7049
7543
|
document.getElementById('preview-modal').classList.add('visible');
|
|
7050
7544
|
updatePreviewLinkBtn();
|
|
7545
|
+
if (kind === 'image') return unmountReview();
|
|
7546
|
+
mountReview({
|
|
7547
|
+
contentEl: bodyEl,
|
|
7548
|
+
panelEl: document.getElementById('preview-review-panel'),
|
|
7549
|
+
hostEl: document.querySelector('#preview-modal .modal'),
|
|
7550
|
+
source: { kind: 'file', label: fileName, path: filePath },
|
|
7551
|
+
frame: isHtml ? bodyEl.querySelector('iframe') : null,
|
|
7552
|
+
context: kind === 'text' ? (range) => ({ line: sourceLineOf(bodyEl, range) }) : null,
|
|
7553
|
+
onSent: closePreviewModal,
|
|
7554
|
+
});
|
|
7555
|
+
}
|
|
7556
|
+
|
|
7557
|
+
function sourceLineOf(bodyEl, range) {
|
|
7558
|
+
const code = bodyEl.querySelector('.preview-source code');
|
|
7559
|
+
if (!code?.contains(range.startContainer)) return undefined;
|
|
7560
|
+
return reviewTextBefore(code, range.startContainer, range.startOffset).split('\n').length;
|
|
7051
7561
|
}
|
|
7052
7562
|
|
|
7053
7563
|
function isPreviewLinkedToCurrentSession() {
|
|
@@ -7098,6 +7608,7 @@ function refreshInfoModalLinkedDocs() {
|
|
|
7098
7608
|
}
|
|
7099
7609
|
|
|
7100
7610
|
function closePreviewModal() {
|
|
7611
|
+
unmountReview(document.getElementById('preview-review-panel'));
|
|
7101
7612
|
hideModalOverlay('preview-modal');
|
|
7102
7613
|
// Empty the body so an iframe preview is destroyed and its scripts/timers stop.
|
|
7103
7614
|
document.getElementById('preview-modal-body').innerHTML = '';
|
|
@@ -7965,20 +8476,30 @@ function renderMarkers(markers) {
|
|
|
7965
8476
|
}
|
|
7966
8477
|
|
|
7967
8478
|
function formatTokens(k) {
|
|
7968
|
-
if (k >= 1000) return `${(k / 1000).toFixed(1)}M`;
|
|
8479
|
+
if (k >= 1000) return `${(k / 1000).toFixed(1).replace(/\.0$/, '')}M`;
|
|
7969
8480
|
if (k < 1) return (k * 1000).toFixed(0);
|
|
7970
8481
|
return `${Math.round(k)}K`;
|
|
7971
8482
|
}
|
|
7972
8483
|
|
|
7973
|
-
|
|
8484
|
+
// session.autoCompact ({window, pct} from the session's settings) makes the compaction point the
|
|
8485
|
+
// bar's full scale, because the status line reports the model's full window regardless.
|
|
8486
|
+
function getCtx(session) {
|
|
8487
|
+
const raw = session?.contextStatus;
|
|
8488
|
+
const autoCompact = session?.autoCompact;
|
|
7974
8489
|
if (!raw) return null;
|
|
7975
8490
|
const cw = raw.context_window || {};
|
|
7976
|
-
const
|
|
7977
|
-
const pct = cw.used_percentage || 0;
|
|
8491
|
+
const modelSize = cw.context_window_size || 0;
|
|
7978
8492
|
const model = raw.model || {};
|
|
7979
8493
|
const modelName = model.display_name || model.id || '';
|
|
7980
8494
|
const thresholds = getModelThresholds(modelName);
|
|
7981
|
-
const usedTokens =
|
|
8495
|
+
const usedTokens = modelSize > 0 ? ((cw.used_percentage || 0) / 100) * modelSize : 0;
|
|
8496
|
+
const compactAt =
|
|
8497
|
+
modelSize > 0 && autoCompact
|
|
8498
|
+
? (Math.min(modelSize, autoCompact.window || modelSize) * (autoCompact.pct || 100)) / 100
|
|
8499
|
+
: 0;
|
|
8500
|
+
const compacts = compactAt > 0 && compactAt < modelSize;
|
|
8501
|
+
const size = compacts ? compactAt : modelSize;
|
|
8502
|
+
const pct = compacts ? Math.min(100, (usedTokens / size) * 100) : cw.used_percentage || 0;
|
|
7982
8503
|
const markers =
|
|
7983
8504
|
size > 0
|
|
7984
8505
|
? [
|
|
@@ -7989,8 +8510,10 @@ function getCtx(raw) {
|
|
|
7989
8510
|
: [];
|
|
7990
8511
|
return {
|
|
7991
8512
|
pct,
|
|
7992
|
-
remaining:
|
|
8513
|
+
remaining: 100 - pct,
|
|
7993
8514
|
size,
|
|
8515
|
+
modelSize,
|
|
8516
|
+
compacts,
|
|
7994
8517
|
usedTokens,
|
|
7995
8518
|
modelName,
|
|
7996
8519
|
inputTokens: cw.total_input_tokens || 0,
|
|
@@ -7999,18 +8522,23 @@ function getCtx(raw) {
|
|
|
7999
8522
|
};
|
|
8000
8523
|
}
|
|
8001
8524
|
|
|
8002
|
-
function renderContextBar(
|
|
8003
|
-
const ctx = getCtx(
|
|
8525
|
+
function renderContextBar(session) {
|
|
8526
|
+
const ctx = getCtx(session);
|
|
8004
8527
|
if (!ctx) return '';
|
|
8005
8528
|
const color = getContextColor(ctx.usedTokens, ctx.modelName);
|
|
8529
|
+
const fmt = (n) => formatTokens(n / 1000);
|
|
8530
|
+
const used = fmt(ctx.usedTokens);
|
|
8531
|
+
const title = ctx.compacts
|
|
8532
|
+
? `Context: ${used} of ${fmt(ctx.size)} tokens before auto-compact (model window ${fmt(ctx.modelSize)})`
|
|
8533
|
+
: `Context: ${used}${ctx.size ? ` of ${fmt(ctx.size)}` : ''} tokens`;
|
|
8006
8534
|
return `
|
|
8007
|
-
<div class="context-bar"
|
|
8535
|
+
<div class="context-bar" title="${escapeHtml(title)}">
|
|
8008
8536
|
<div class="context-bar-track">
|
|
8009
8537
|
<div class="context-bar-fill" style="width:${ctx.pct}%;background:${color}"></div>
|
|
8010
8538
|
${renderMarkers(ctx.markers)}
|
|
8011
8539
|
</div>
|
|
8012
8540
|
<div class="context-bar-labels">
|
|
8013
|
-
<span style="color:${color}">${Math.round(ctx.pct)}% (${
|
|
8541
|
+
<span style="color:${color}">${Math.round(ctx.pct)}% (${used})</span>
|
|
8014
8542
|
<span>${Math.round(ctx.remaining)}% free</span>
|
|
8015
8543
|
</div>
|
|
8016
8544
|
</div>`;
|
|
@@ -8021,9 +8549,10 @@ function formatCost(usd) {
|
|
|
8021
8549
|
return `$${usd.toFixed(2)}`;
|
|
8022
8550
|
}
|
|
8023
8551
|
|
|
8024
|
-
function renderContextDetail(
|
|
8025
|
-
const ctx = getCtx(
|
|
8552
|
+
function renderContextDetail(session, { tokens = true } = {}) {
|
|
8553
|
+
const ctx = getCtx(session);
|
|
8026
8554
|
if (!ctx) return '';
|
|
8555
|
+
const raw = session.contextStatus;
|
|
8027
8556
|
const totalK = ctx.size / 1000;
|
|
8028
8557
|
const color = getContextColor(ctx.usedTokens, ctx.modelName);
|
|
8029
8558
|
|
|
@@ -8052,7 +8581,7 @@ function renderContextDetail(raw, { tokens = true } = {}) {
|
|
|
8052
8581
|
</div>
|
|
8053
8582
|
<div class="detail-context-summary">
|
|
8054
8583
|
<span style="color:${color}">${Math.round(ctx.pct)}% used</span>
|
|
8055
|
-
<span
|
|
8584
|
+
<span${ctx.compacts ? ` title="Auto-compact window · model window ${escapeHtml(formatTokens(ctx.modelSize / 1000))}"` : ''}>${formatTokens(ctx.usedTokens / 1000)} / ${formatTokens(totalK)}</span>
|
|
8056
8585
|
</div>
|
|
8057
8586
|
<div class="detail-context-stats">
|
|
8058
8587
|
${tokenRows}
|
|
@@ -8292,6 +8821,11 @@ function isSessionActive(s) {
|
|
|
8292
8821
|
return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
|
|
8293
8822
|
}
|
|
8294
8823
|
|
|
8824
|
+
// An active pinned session renders outside the Pinned group until the user dismisses it.
|
|
8825
|
+
function isInPinnedGroup(s) {
|
|
8826
|
+
return isPlacedPinned(s.id) && !isPlacedSticky(s.id) && (!isSessionActive(s) || dismissedSessionIds.has(s.id));
|
|
8827
|
+
}
|
|
8828
|
+
|
|
8295
8829
|
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
8830
|
|
|
8297
8831
|
function sessionDisplayName(session) {
|
|
@@ -9394,7 +9928,10 @@ async function showSessionInfoModal(sessionId) {
|
|
|
9394
9928
|
const planPromise = fetch(`/api/sessions/${sessionId}/plan`)
|
|
9395
9929
|
.then((r) => (r.ok ? r.json() : null))
|
|
9396
9930
|
.catch(() => null)
|
|
9397
|
-
.then((data) =>
|
|
9931
|
+
.then((data) => {
|
|
9932
|
+
if (data?.content) _pendingPlanPath = data.path || null;
|
|
9933
|
+
return data?.content || null;
|
|
9934
|
+
});
|
|
9398
9935
|
|
|
9399
9936
|
const tasksPromise =
|
|
9400
9937
|
cachedTasks.length > 0
|
|
@@ -9418,6 +9955,7 @@ async function showSessionInfoModal(sessionId) {
|
|
|
9418
9955
|
|
|
9419
9956
|
let _infoModalSessionId = null;
|
|
9420
9957
|
let _pendingPlanContent = null;
|
|
9958
|
+
let _pendingPlanPath = null;
|
|
9421
9959
|
|
|
9422
9960
|
function updateStickyBtnState() {
|
|
9423
9961
|
const stickyBtn = document.getElementById('session-info-sticky-btn');
|
|
@@ -9542,7 +10080,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
9542
10080
|
|
|
9543
10081
|
if (session.contextStatus) {
|
|
9544
10082
|
html += `<hr style="border: none; border-top: 1px solid var(--border); margin: 12px 0;">`;
|
|
9545
|
-
html += renderContextDetail(session
|
|
10083
|
+
html += renderContextDetail(session);
|
|
9546
10084
|
}
|
|
9547
10085
|
|
|
9548
10086
|
if (planContent) {
|
|
@@ -10061,10 +10599,11 @@ function refreshOpenPlan() {
|
|
|
10061
10599
|
fetch(`/api/sessions/${_planSessionId}/plan`)
|
|
10062
10600
|
.then((r) => (r.ok ? r.json() : null))
|
|
10063
10601
|
.then((data) => {
|
|
10064
|
-
if (data?.content) {
|
|
10602
|
+
if (data?.content && (data.content !== _pendingPlanContent || data.path !== _pendingPlanPath)) {
|
|
10065
10603
|
_pendingPlanContent = data.content;
|
|
10066
|
-
|
|
10067
|
-
body.innerHTML = renderMarkdown(_pendingPlanContent);
|
|
10604
|
+
_pendingPlanPath = data.path || null;
|
|
10605
|
+
document.getElementById('plan-modal-body').innerHTML = renderMarkdown(_pendingPlanContent);
|
|
10606
|
+
mountPlanReview();
|
|
10068
10607
|
}
|
|
10069
10608
|
})
|
|
10070
10609
|
.catch(() => {});
|
|
@@ -10383,6 +10922,7 @@ function openPlanForSession(sid) {
|
|
|
10383
10922
|
.then((data) => {
|
|
10384
10923
|
if (data?.content) {
|
|
10385
10924
|
_pendingPlanContent = data.content;
|
|
10925
|
+
_pendingPlanPath = data.path || null;
|
|
10386
10926
|
_planSessionId = sid;
|
|
10387
10927
|
openPlanModal();
|
|
10388
10928
|
}
|
|
@@ -10403,10 +10943,12 @@ function openPlanModal() {
|
|
|
10403
10943
|
document.querySelector('#plan-modal .modal-footer').prepend(controls);
|
|
10404
10944
|
}
|
|
10405
10945
|
document.getElementById('plan-modal').classList.add('visible');
|
|
10946
|
+
mountPlanReview();
|
|
10406
10947
|
const keyHandler = (e) => {
|
|
10407
10948
|
if (e.key === 'Escape') {
|
|
10408
10949
|
e.preventDefault();
|
|
10409
10950
|
e.stopPropagation();
|
|
10951
|
+
if (reviewPopMode) return closeReviewPop();
|
|
10410
10952
|
closePlanModal();
|
|
10411
10953
|
document.removeEventListener('keydown', keyHandler, true);
|
|
10412
10954
|
}
|
|
@@ -10414,7 +10956,20 @@ function openPlanModal() {
|
|
|
10414
10956
|
document.addEventListener('keydown', keyHandler, true);
|
|
10415
10957
|
}
|
|
10416
10958
|
|
|
10959
|
+
function mountPlanReview() {
|
|
10960
|
+
if (!_pendingPlanPath || !_planSessionId) return;
|
|
10961
|
+
mountReview({
|
|
10962
|
+
contentEl: document.getElementById('plan-modal-body'),
|
|
10963
|
+
panelEl: document.getElementById('plan-review-panel'),
|
|
10964
|
+
hostEl: document.querySelector('#plan-modal .modal'),
|
|
10965
|
+
source: { kind: 'file', label: pathBasename(_pendingPlanPath), path: _pendingPlanPath },
|
|
10966
|
+
sessionId: _planSessionId,
|
|
10967
|
+
onSent: closePlanModal,
|
|
10968
|
+
});
|
|
10969
|
+
}
|
|
10970
|
+
|
|
10417
10971
|
function closePlanModal() {
|
|
10972
|
+
unmountReview(document.getElementById('plan-review-panel'));
|
|
10418
10973
|
hideModalOverlay('plan-modal');
|
|
10419
10974
|
}
|
|
10420
10975
|
|
|
@@ -11571,6 +12126,7 @@ function placeholderSession(id, spec) {
|
|
|
11571
12126
|
name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
11572
12127
|
dispatchGroup: spec.group || undefined,
|
|
11573
12128
|
startedBy: spec.startedBy || undefined,
|
|
12129
|
+
dispatched: spec.mode === 'dispatch' ? { parent: spec.startedBy || null } : undefined,
|
|
11574
12130
|
project: spec.cwd,
|
|
11575
12131
|
worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
|
|
11576
12132
|
modifiedAt: new Date(spec.startedAt).toISOString(),
|
|
@@ -12234,7 +12790,12 @@ fetch('/api/config')
|
|
|
12234
12790
|
})
|
|
12235
12791
|
.catch(() => {})
|
|
12236
12792
|
.then(restorePendingSessions)
|
|
12237
|
-
.then(() =>
|
|
12793
|
+
.then(() =>
|
|
12794
|
+
fetchSessions(
|
|
12795
|
+
true,
|
|
12796
|
+
urlState.projectView ? null : urlState.session || (lastView?.view === 'session' ? lastView.session : null),
|
|
12797
|
+
),
|
|
12798
|
+
)
|
|
12238
12799
|
.then(async () => {
|
|
12239
12800
|
if (urlState.projectView) {
|
|
12240
12801
|
try {
|