claude-code-kanban 4.26.0 → 4.28.0-rc.1
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 +68 -44
- package/cli.js +235 -7
- package/lib/claude-dir.js +1 -1
- package/lib/dispatch-groups.js +93 -0
- package/lib/dispatch.js +171 -0
- package/lib/folder-dialog.js +91 -0
- package/lib/inline-assets.js +1 -1
- package/lib/net-guard.js +20 -3
- package/lib/parsers.js +113 -0
- package/lib/scratch-files.js +47 -0
- package/lib/session-events.js +22 -2
- package/lib/terminal.js +555 -0
- package/package.json +14 -3
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/monitors.json +6 -0
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +34 -2
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +7 -2
- package/plugin/plugins/claude-code-kanban/skills/kanban-dispatch/SKILL.md +23 -0
- package/public/app.js +2250 -214
- package/public/index.html +155 -14
- package/public/style.css +1048 -132
- package/public/sw.js +2 -1
- package/public/themes.css +84 -80
- package/server.js +647 -91
- package/skill-guides/dispatch.md +72 -0
package/public/app.js
CHANGED
|
@@ -24,6 +24,8 @@ const FILTER_DEFAULTS = { project: '__recent__', session: 'active', limit: '20'
|
|
|
24
24
|
|
|
25
25
|
let sessions = [];
|
|
26
26
|
let currentSessionId = null;
|
|
27
|
+
let lastSessionId = null;
|
|
28
|
+
let previousSessionId = null;
|
|
27
29
|
let currentTasks = [];
|
|
28
30
|
let viewMode = 'session';
|
|
29
31
|
let sessionFilter = FILTER_DEFAULTS.session;
|
|
@@ -45,6 +47,8 @@ const SECTION_PROJECTS = '__section_projects__';
|
|
|
45
47
|
const SECTION_SESSIONS = '__section_sessions__';
|
|
46
48
|
let stableGroupOrder = []; // cached project path order to prevent jumping
|
|
47
49
|
let sessionGroups = []; // user-named groups: [{id, name, color, members:[{type,ref}]}]
|
|
50
|
+
// Sessions the user pulled out of their dispatch placement; the server's group map is not theirs to edit.
|
|
51
|
+
let sgReleased = new Set();
|
|
48
52
|
let sgDrag = null; // in-flight sidebar drag: {kind:'session'|'project'|'group', ref}
|
|
49
53
|
let searchQuery = ''; // Search query for fuzzy search
|
|
50
54
|
let allTasksCache = []; // Cache all tasks for search
|
|
@@ -58,12 +62,24 @@ let lastMessagesHash = '';
|
|
|
58
62
|
let currentMessages = [];
|
|
59
63
|
let agentDurationInterval = null;
|
|
60
64
|
let agentPollInterval = null;
|
|
65
|
+
// Inside the hub an inactive app is a display:none iframe whose document.hidden stays false,
|
|
66
|
+
// so the hub tells us via hub:active. Standalone, or if that message is lost, cck stays active.
|
|
67
|
+
let hubActive = true;
|
|
68
|
+
let missedWhileHidden = false;
|
|
69
|
+
const isOnScreen = () => hubActive && !document.hidden;
|
|
70
|
+
function skipOffScreen() {
|
|
71
|
+
if (isOnScreen()) return false;
|
|
72
|
+
missedWhileHidden = true;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
const METADATA_MAX_WAIT_MS = 5000;
|
|
61
76
|
let selectedTaskId = null;
|
|
62
77
|
let selectedSessionId = null;
|
|
63
78
|
// Task stays selected (keyboard nav) but its white highlight is dimmed once the detail panel closes.
|
|
64
79
|
let taskHighlightDimmed = false;
|
|
65
80
|
let focusZone = 'board'; // 'board' | 'sidebar'
|
|
66
81
|
let appConfig = { marketplaceUrl: null, costUrl: null, memoryUrl: null, scratchAvailable: false };
|
|
82
|
+
let runningTerminals = new Set();
|
|
67
83
|
let selectedSessionIdx = -1;
|
|
68
84
|
let selectedSessionKbId = null;
|
|
69
85
|
let sessionJustSelected = false;
|
|
@@ -117,6 +133,7 @@ function updateUrl() {
|
|
|
117
133
|
const url = qs ? `?${qs}` : window.location.pathname;
|
|
118
134
|
history.replaceState(null, '', url);
|
|
119
135
|
persistLastView();
|
|
136
|
+
syncTerminal();
|
|
120
137
|
}
|
|
121
138
|
|
|
122
139
|
const LAST_VIEW_KEY = 'lastView';
|
|
@@ -200,15 +217,16 @@ async function fetchSessions(includeTasks = true) {
|
|
|
200
217
|
const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
|
|
201
218
|
if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
|
|
202
219
|
if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
|
|
203
|
-
// When server filters by activity, the focused session may not be active —
|
|
204
|
-
// include it in pinned so the server still returns it.
|
|
205
|
-
if ((sessionFilter === 'active' || zenMode) && currentSessionId) allPinnedIds.add(currentSessionId);
|
|
206
220
|
const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
|
|
221
|
+
// The focused session must come back whatever the filters say: renderSession and the
|
|
222
|
+
// info modal both look it up in `sessions` and bail when it is absent, so a session
|
|
223
|
+
// opened from outside the current filter would leave the view on the previous one.
|
|
224
|
+
const includeParam = currentSessionId ? `&include=${encodeURIComponent(currentSessionId)}` : '';
|
|
207
225
|
const projectParam =
|
|
208
226
|
filterProject && filterProject !== '__recent__' ? `&project=${encodeURIComponent(filterProject)}` : '';
|
|
209
227
|
const filterParam = sessionFilter === 'active' ? '&filter=active' : '';
|
|
210
228
|
const sessionsPromise = fetch(
|
|
211
|
-
`/api/sessions?limit=${sessionLimit}${pinnedParam}${projectParam}${filterParam}`,
|
|
229
|
+
`/api/sessions?limit=${sessionLimit}${pinnedParam}${includeParam}${projectParam}${filterParam}`,
|
|
212
230
|
).then((r) => r.json());
|
|
213
231
|
|
|
214
232
|
let newSessions, newTasks;
|
|
@@ -229,7 +247,7 @@ async function fetchSessions(includeTasks = true) {
|
|
|
229
247
|
}
|
|
230
248
|
lastSessionsHash = sessionsHash;
|
|
231
249
|
|
|
232
|
-
sessions = newSessions;
|
|
250
|
+
sessions = mergePlaceholders(newSessions);
|
|
233
251
|
renderSessions();
|
|
234
252
|
renderActivityChip();
|
|
235
253
|
} catch (error) {
|
|
@@ -237,6 +255,16 @@ async function fetchSessions(includeTasks = true) {
|
|
|
237
255
|
}
|
|
238
256
|
}
|
|
239
257
|
|
|
258
|
+
// One session by id, ignoring every sidebar filter — that is what `include` means.
|
|
259
|
+
async function fetchSessionById(id) {
|
|
260
|
+
try {
|
|
261
|
+
const r = await fetch(`/api/sessions?limit=1&include=${encodeURIComponent(id)}`);
|
|
262
|
+
return r.ok ? (await r.json()).find((s) => s.id === id) || null : null;
|
|
263
|
+
} catch (_) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
240
268
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
241
269
|
function handleSearch(query) {
|
|
242
270
|
searchQuery = query.toLowerCase().trim();
|
|
@@ -416,14 +444,8 @@ async function fetchTasks(sessionId) {
|
|
|
416
444
|
document.getElementById('message-toggle')?.style.removeProperty('display');
|
|
417
445
|
const res = await fetch(`/api/sessions/${sessionId}`);
|
|
418
446
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
newTasks = await res.json();
|
|
422
|
-
} else if (res.status === 404) {
|
|
423
|
-
newTasks = [];
|
|
424
|
-
} else {
|
|
425
|
-
throw new Error(`Failed to fetch tasks: ${res.status}`);
|
|
426
|
-
}
|
|
447
|
+
if (!res.ok) throw new Error(`Failed to fetch tasks: ${res.status}`);
|
|
448
|
+
const newTasks = await res.json();
|
|
427
449
|
|
|
428
450
|
const hash = JSON.stringify(newTasks);
|
|
429
451
|
if (sessionId === currentSessionId && hash === lastCurrentTasksHash) {
|
|
@@ -435,6 +457,7 @@ async function fetchTasks(sessionId) {
|
|
|
435
457
|
if (agentLogMode && sessionId !== currentSessionId) exitAgentLogMode();
|
|
436
458
|
if (sessionId !== currentSessionId && document.getElementById('scratchpad-modal').classList.contains('visible'))
|
|
437
459
|
closeScratchpad();
|
|
460
|
+
if (sessionId !== currentSessionId && detailPanel.classList.contains('visible')) closeDetailPanel();
|
|
438
461
|
if (revealedPlanSessionId && sessionId !== revealedPlanSessionId) {
|
|
439
462
|
revealedPlanSessionId = null;
|
|
440
463
|
}
|
|
@@ -442,6 +465,10 @@ async function fetchTasks(sessionId) {
|
|
|
442
465
|
revealedStorageSessionId = null;
|
|
443
466
|
}
|
|
444
467
|
if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
|
|
468
|
+
if (lastSessionId !== sessionId) {
|
|
469
|
+
previousSessionId = lastSessionId;
|
|
470
|
+
lastSessionId = sessionId;
|
|
471
|
+
}
|
|
445
472
|
currentSessionId = sessionId;
|
|
446
473
|
currentPins = loadPins(sessionId);
|
|
447
474
|
ownerFilter = '';
|
|
@@ -608,6 +635,7 @@ async function refreshProjectAgents() {
|
|
|
608
635
|
function toggleMessagePanel() {
|
|
609
636
|
const panel = document.getElementById('message-panel');
|
|
610
637
|
messagePanelOpen = !messagePanelOpen;
|
|
638
|
+
termState.panelHidden = false;
|
|
611
639
|
store.setItem('message-panel-open', messagePanelOpen);
|
|
612
640
|
panel.classList.toggle('visible', messagePanelOpen);
|
|
613
641
|
document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
|
|
@@ -1154,11 +1182,22 @@ function renderMessageListBody(messages) {
|
|
|
1154
1182
|
|
|
1155
1183
|
const clickable = `data-msg-idx="${i}" onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" style="cursor:pointer"`;
|
|
1156
1184
|
const pinBtn = renderMsgPinBtn(m, i);
|
|
1185
|
+
|
|
1157
1186
|
if (m.type === 'user') {
|
|
1158
1187
|
if (m.systemLabel) {
|
|
1188
|
+
// A message another agent sent into this session (subagent hand-back, peer
|
|
1189
|
+
// session) is agent output: the agent icon, a sender label, and a markdown
|
|
1190
|
+
// preview of the report — never the person icon.
|
|
1191
|
+
const preview = m.agentMessage ? buildAssistantPreview(m.fullText || m.text) : null;
|
|
1192
|
+
const bodyHtml = preview?.md
|
|
1193
|
+
? `<div class="msg-text msg-text-md">
|
|
1194
|
+
<div class="msg-md-content rendered-md${preview.remainder ? ' is-truncated' : ''}">${renderMarkdown(preview.md)}</div>${preview.remainder ? `<div class="msg-md-more">${escapeHtml(preview.remainder)}</div>` : ''}
|
|
1195
|
+
</div>`
|
|
1196
|
+
: '';
|
|
1197
|
+
const queuedTag = m.queued ? '<span class="msg-queued-tag">queued</span>' : '';
|
|
1159
1198
|
parts.push(`<div class="msg-item msg-system" ${clickable}>
|
|
1160
|
-
${MSG_ICON_SYSTEM}
|
|
1161
|
-
<div class="msg-body"><div class="msg-text"><code>${escapeHtml(m.systemLabel)}</code></div
|
|
1199
|
+
${m.agentMessage ? ICON_AGENT : MSG_ICON_SYSTEM}
|
|
1200
|
+
<div class="msg-body"><div class="msg-text"><code>${escapeHtml(m.systemLabel)}</code></div>${bodyHtml}<div class="msg-time">${formatDate(m.timestamp)}${queuedTag}</div></div>${pinBtn}
|
|
1162
1201
|
</div>`);
|
|
1163
1202
|
} else {
|
|
1164
1203
|
const cmd = parseCommandMessage(m.text);
|
|
@@ -1392,9 +1431,13 @@ function clearWaitingUi() {
|
|
|
1392
1431
|
waitingCustomDraft = {};
|
|
1393
1432
|
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
1394
1433
|
// In follow mode stay in the modal and swap to the latest message —
|
|
1395
|
-
// closing it would silently drop the user out of following.
|
|
1396
|
-
|
|
1397
|
-
|
|
1434
|
+
// closing it would silently drop the user out of following. An ask that
|
|
1435
|
+
// opened its own window is the exception: there was no following to drop.
|
|
1436
|
+
if (msgDetailFollowLatest && !waitingDetailAutoOpened && currentMessages.length) {
|
|
1437
|
+
showMsgDetail(currentMessages.length - 1);
|
|
1438
|
+
} else {
|
|
1439
|
+
closeMsgDetailModal();
|
|
1440
|
+
}
|
|
1398
1441
|
}
|
|
1399
1442
|
// The saved-plan modal stays open after a decision — only its approval row goes
|
|
1400
1443
|
document.getElementById('plan-approval-footer')?.remove();
|
|
@@ -1482,6 +1525,8 @@ const ICON_AGENT_WAITING =
|
|
|
1482
1525
|
'<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"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
|
|
1483
1526
|
const ICON_AGENT_ACTIVE =
|
|
1484
1527
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>';
|
|
1528
|
+
const ICON_TERMINAL =
|
|
1529
|
+
'<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>';
|
|
1485
1530
|
const ICON_CHAT =
|
|
1486
1531
|
'<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>';
|
|
1487
1532
|
const TOOL_ICONS = {
|
|
@@ -1985,7 +2030,7 @@ function showMsgDetail(idx) {
|
|
|
1985
2030
|
const rawText = stripAnsi(m.fullText || m.text || '');
|
|
1986
2031
|
const cmd = m.type === 'user' ? parseCommandMessage(rawText) : null;
|
|
1987
2032
|
document.getElementById('msg-detail-title').textContent =
|
|
1988
|
-
m.type === 'assistant' ? 'Claude' : m.systemLabel ? 'System' : 'User';
|
|
2033
|
+
m.type === 'assistant' ? 'Claude' : m.agentMessage ? m.systemLabel : m.systemLabel ? 'System' : 'User';
|
|
1989
2034
|
document.getElementById('msg-detail-agent-btn').style.display = 'none';
|
|
1990
2035
|
const userExtras = m.type === 'user' ? renderUserAttachments(m) : '';
|
|
1991
2036
|
if (m.compactSummary) {
|
|
@@ -2023,6 +2068,7 @@ function showMsgDetail(idx) {
|
|
|
2023
2068
|
function closeMsgDetailModal() {
|
|
2024
2069
|
hideModalOverlay('msg-detail-modal');
|
|
2025
2070
|
msgDetailFollowLatest = false;
|
|
2071
|
+
waitingDetailAutoOpened = false;
|
|
2026
2072
|
// Drop the message highlight on close, mirroring task-card behavior.
|
|
2027
2073
|
msgHighlightDimmed = true;
|
|
2028
2074
|
const container = document.getElementById('message-panel-content');
|
|
@@ -2536,6 +2582,17 @@ function sanitizeOutputHtml(text) {
|
|
|
2536
2582
|
return typeof text === 'string' ? ansiToHtml(stripLineNumbers(text)) : '';
|
|
2537
2583
|
}
|
|
2538
2584
|
|
|
2585
|
+
function toolOutputHtml(text) {
|
|
2586
|
+
if (typeof text === 'string' && text.length <= HLJS_MAX_CHARS && /^\s*[[{]/.test(text)) {
|
|
2587
|
+
try {
|
|
2588
|
+
const pretty = JSON.stringify(JSON.parse(text), null, 2);
|
|
2589
|
+
if (typeof hljs === 'undefined' || !hljs.getLanguage('json')) return escapeHtml(pretty);
|
|
2590
|
+
return `<code class="hljs language-json">${hljs.highlight(pretty, { language: 'json' }).value}</code>`;
|
|
2591
|
+
} catch (_) {}
|
|
2592
|
+
}
|
|
2593
|
+
return sanitizeOutputHtml(text);
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2539
2596
|
function highlightBash(escaped) {
|
|
2540
2597
|
return escaped
|
|
2541
2598
|
.replace(/^(\s*)(#.*)$/gm, '$1<span style="color:#6a9955">$2</span>')
|
|
@@ -2601,11 +2658,11 @@ function autoSizeModal(modal, body) {
|
|
|
2601
2658
|
|
|
2602
2659
|
function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
|
|
2603
2660
|
if (!toolResult) return '';
|
|
2604
|
-
const escaped =
|
|
2661
|
+
const escaped = toolOutputHtml(toolResult);
|
|
2605
2662
|
let truncLabel = '',
|
|
2606
2663
|
fullBlock = '';
|
|
2607
2664
|
if (isTruncated && fullResult) {
|
|
2608
|
-
const toggle = makeExpandToggle(escaped,
|
|
2665
|
+
const toggle = makeExpandToggle(escaped, toolOutputHtml(fullResult));
|
|
2609
2666
|
truncLabel = toggle.btn;
|
|
2610
2667
|
fullBlock = toggle.full;
|
|
2611
2668
|
} else if (isTruncated && toolUseId) {
|
|
@@ -2647,7 +2704,7 @@ async function _toggleToolResultExpand(btn) {
|
|
|
2647
2704
|
);
|
|
2648
2705
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
2649
2706
|
const { content } = await r.json();
|
|
2650
|
-
f.innerHTML =
|
|
2707
|
+
f.innerHTML = toolOutputHtml(content);
|
|
2651
2708
|
btn.dataset.loaded = '1';
|
|
2652
2709
|
} catch (_e) {
|
|
2653
2710
|
btn.textContent = 'Show more';
|
|
@@ -2838,9 +2895,10 @@ function renderAgentFooter() {
|
|
|
2838
2895
|
|
|
2839
2896
|
clearInterval(agentDurationInterval);
|
|
2840
2897
|
if (visible.some((a) => a.status === 'active' || a.status === 'idle')) {
|
|
2841
|
-
agentDurationInterval = setInterval(() => renderAgentFooter(), 1000);
|
|
2898
|
+
agentDurationInterval = setInterval(() => isOnScreen() && renderAgentFooter(), 1000);
|
|
2842
2899
|
if (!agentPollInterval) {
|
|
2843
2900
|
agentPollInterval = setInterval(() => {
|
|
2901
|
+
if (skipOffScreen()) return;
|
|
2844
2902
|
if (viewMode === 'project' && currentProjectPath) {
|
|
2845
2903
|
refreshProjectAgents();
|
|
2846
2904
|
} else if (currentSessionId) {
|
|
@@ -2849,7 +2907,7 @@ function renderAgentFooter() {
|
|
|
2849
2907
|
}, 3000);
|
|
2850
2908
|
}
|
|
2851
2909
|
} else {
|
|
2852
|
-
agentDurationInterval = setInterval(() => renderAgentFooter(), 10000);
|
|
2910
|
+
agentDurationInterval = setInterval(() => isOnScreen() && renderAgentFooter(), 10000);
|
|
2853
2911
|
clearInterval(agentPollInterval);
|
|
2854
2912
|
agentPollInterval = null;
|
|
2855
2913
|
}
|
|
@@ -3021,6 +3079,13 @@ async function revealSession(id) {
|
|
|
3021
3079
|
await fetchSessions();
|
|
3022
3080
|
session = sessions.find((s) => s.id === id);
|
|
3023
3081
|
}
|
|
3082
|
+
if (!session) {
|
|
3083
|
+
// The id is outside the list the filters asked for. renderSession bails on a session
|
|
3084
|
+
// missing from `sessions`, so pull that one row in before opening it; later polls
|
|
3085
|
+
// keep it, because fetchSessions pins currentSessionId.
|
|
3086
|
+
session = await fetchSessionById(id);
|
|
3087
|
+
if (session) sessions.push(session);
|
|
3088
|
+
}
|
|
3024
3089
|
const uncollapsed = session ? uncollapseFor(session) : false;
|
|
3025
3090
|
if (uncollapsed) persistCollapsedGroups();
|
|
3026
3091
|
expandSidebar();
|
|
@@ -3101,11 +3166,12 @@ function getFilteredSessions() {
|
|
|
3101
3166
|
filteredSessions = filteredSessions.filter((s) => {
|
|
3102
3167
|
if (dismissedSessionIds.has(s.id)) return false;
|
|
3103
3168
|
const isActive =
|
|
3104
|
-
s.
|
|
3105
|
-
(
|
|
3106
|
-
s.
|
|
3107
|
-
|
|
3108
|
-
|
|
3169
|
+
runningTerminals.has(s.id) ||
|
|
3170
|
+
(s.hasMessages &&
|
|
3171
|
+
((!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0)) ||
|
|
3172
|
+
s.hasActiveAgents ||
|
|
3173
|
+
s.hasWaitingForUser ||
|
|
3174
|
+
s.hasRecentActivity));
|
|
3109
3175
|
if (isActive) activeSessionIds.add(s.id);
|
|
3110
3176
|
return isActive;
|
|
3111
3177
|
});
|
|
@@ -3124,7 +3190,10 @@ function getFilteredSessions() {
|
|
|
3124
3190
|
}
|
|
3125
3191
|
}
|
|
3126
3192
|
if (filterProject) {
|
|
3127
|
-
|
|
3193
|
+
// The open session keeps its row even when it belongs to another project, so that
|
|
3194
|
+
// revealing one by id has something to scroll to. Same exemption the active branch
|
|
3195
|
+
// above already makes, for the same reason.
|
|
3196
|
+
filteredSessions = filteredSessions.filter((s) => matchesProjectFilter(s.project) || s.id === currentSessionId);
|
|
3128
3197
|
}
|
|
3129
3198
|
|
|
3130
3199
|
if (activityFilter.size > 0) {
|
|
@@ -3230,6 +3299,7 @@ function renderSessions() {
|
|
|
3230
3299
|
|
|
3231
3300
|
// Helper to render a single session card
|
|
3232
3301
|
const renderSessionCard = (session) => {
|
|
3302
|
+
if (session.placeholder) return renderPlaceholderCard(session);
|
|
3233
3303
|
const total = session.taskCount;
|
|
3234
3304
|
const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
|
|
3235
3305
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
@@ -3258,11 +3328,11 @@ function renderSessions() {
|
|
|
3258
3328
|
const showCtx = !!session.contextStatus && !zenMode;
|
|
3259
3329
|
const linkedDocsCount = getSessionPreviewPaths(session.id).length;
|
|
3260
3330
|
const bookmarksCount = loadPins(session.id).length;
|
|
3261
|
-
const hasScratchpad =
|
|
3331
|
+
const hasScratchpad = _hasScratchpad(_sessionScratchpadKey(session.id));
|
|
3262
3332
|
const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
|
|
3263
3333
|
const sid = escAttrJs(session.id);
|
|
3264
3334
|
return `
|
|
3265
|
-
<button onclick="
|
|
3335
|
+
<button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
|
|
3266
3336
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
3267
3337
|
<div class="session-name">${escapeHtml(sessionName)}</div>
|
|
3268
3338
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
@@ -3281,6 +3351,7 @@ function renderSessions() {
|
|
|
3281
3351
|
${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
|
|
3282
3352
|
${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escAttrJs(session.planSourceSessionId)}')">${ICON_PLAN}</span>` : ''}
|
|
3283
3353
|
${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
|
|
3354
|
+
${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="Running in a terminal here">${ICON_TERMINAL}</span>` : ''}
|
|
3284
3355
|
${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
|
|
3285
3356
|
${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
|
|
3286
3357
|
${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
|
|
@@ -3299,13 +3370,16 @@ function renderSessions() {
|
|
|
3299
3370
|
if (zenMode) {
|
|
3300
3371
|
sessionsList.innerHTML = `${renderSessionCard(zenSession)}
|
|
3301
3372
|
<div class="zen-panel">
|
|
3302
|
-
${renderContextDetail(zenSession.contextStatus) || '<div class="zen-panel-empty">No context data for this session</div>'}
|
|
3373
|
+
${renderContextDetail(zenSession.contextStatus, { tokens: false }) || '<div class="zen-panel-empty">No context data for this session</div>'}
|
|
3303
3374
|
${renderScratchpadRow(zenSession)}
|
|
3304
3375
|
${renderLinkedDocsHtml(zenSession.id)}
|
|
3305
3376
|
${renderArtifactsHtml(zenSession.id)}
|
|
3306
|
-
</div
|
|
3377
|
+
</div>
|
|
3378
|
+
${zenSession.hasWorkflow ? renderWorkflowLiveHtml(zenSession.id) : ''}`;
|
|
3307
3379
|
bindLinkedDocsHandlers(sessionsList.querySelector('.linked-docs-section'), zenSession.id);
|
|
3308
|
-
|
|
3380
|
+
artifactsSection.load(zenSession.id);
|
|
3381
|
+
scratchFilesSection.load(zenSession.id);
|
|
3382
|
+
if (zenSession.hasWorkflow) pollWorkflowLive(zenSession.id);
|
|
3309
3383
|
return;
|
|
3310
3384
|
}
|
|
3311
3385
|
|
|
@@ -3376,10 +3450,12 @@ function renderSessions() {
|
|
|
3376
3450
|
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}${nestedCls}" draggable="true" data-group-path="${escapedPath}" data-project-path="${escapedPath}">
|
|
3377
3451
|
${groupChevronSvg()}
|
|
3378
3452
|
<span class="group-name">${escapeHtml(folderName)}</span>
|
|
3379
|
-
${
|
|
3453
|
+
${terminalAvailable() ? `<span class="project-new-btn" data-project-path="${escapedPath}" title="New session in ${escapeHtml(folderName)}">${PLUS_SVG}</span>` : ''}
|
|
3454
|
+
${headerPadBtnHtml(_projectScratchpadKey(projectPath), folderName)}
|
|
3380
3455
|
<span class="project-view-btn" data-project-path="${escapedPath}" title="Open project view — combined tasks from all sessions">
|
|
3381
3456
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
|
3382
3457
|
</span>
|
|
3458
|
+
${countHtml(projectSessions)}
|
|
3383
3459
|
</div>
|
|
3384
3460
|
<div class="project-group-breadcrumb${nestedCls}" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
|
|
3385
3461
|
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}${nestedCls}" data-project-path="${escapedPath}">
|
|
@@ -3391,28 +3467,27 @@ function renderSessions() {
|
|
|
3391
3467
|
// Named groups are a pure partition of the already-filtered list: whatever they don't claim
|
|
3392
3468
|
// falls through to the project blocks below, which look exactly as they did before groups.
|
|
3393
3469
|
const sgBuckets = new Map();
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
} else {
|
|
3403
|
-
sgRest.push(session);
|
|
3404
|
-
}
|
|
3470
|
+
const sgRest = [];
|
|
3471
|
+
for (const session of filteredSessions) {
|
|
3472
|
+
const group = sgGroupForSession(session);
|
|
3473
|
+
if (group) {
|
|
3474
|
+
if (!sgBuckets.has(group.id)) sgBuckets.set(group.id, []);
|
|
3475
|
+
sgBuckets.get(group.id).push(session);
|
|
3476
|
+
} else {
|
|
3477
|
+
sgRest.push(session);
|
|
3405
3478
|
}
|
|
3406
3479
|
}
|
|
3480
|
+
for (const [name, g] of transientGroups) if (!sgBuckets.has(g.id)) transientGroups.delete(name);
|
|
3407
3481
|
// A group emptied by a filter hides its header; a group the user just created (no filters)
|
|
3408
3482
|
// shows its drop-here state instead of vanishing.
|
|
3409
3483
|
const sgFiltering = !!searchQuery || activityFilter.size > 0 || (!!filterProject && filterProject !== '__recent__');
|
|
3410
3484
|
|
|
3411
3485
|
// flat = the "All sessions" view, which has no project sub-blocks.
|
|
3412
3486
|
const sgSectionHtml = (flat) => {
|
|
3413
|
-
|
|
3487
|
+
const transient = [...transientGroups.values()];
|
|
3488
|
+
if (sessionGroups.length === 0 && transient.length === 0) return '';
|
|
3414
3489
|
let out = '';
|
|
3415
|
-
for (const group of sessionGroups) {
|
|
3490
|
+
for (const group of [...sessionGroups, ...transient]) {
|
|
3416
3491
|
const bucket = sgBuckets.get(group.id) || [];
|
|
3417
3492
|
if (bucket.length === 0 && sgFiltering) continue;
|
|
3418
3493
|
if (!groupPinned) bucket.sort(pinSort);
|
|
@@ -3420,6 +3495,17 @@ function renderSessions() {
|
|
|
3420
3495
|
let body = '';
|
|
3421
3496
|
if (flat) {
|
|
3422
3497
|
body = bucket.map(renderSessionCard).join('');
|
|
3498
|
+
} else if (group.transient) {
|
|
3499
|
+
// Each session under its own project block, so a worktree session reads as its repo's.
|
|
3500
|
+
const byProject = new Map();
|
|
3501
|
+
for (const s of bucket) {
|
|
3502
|
+
const key = s.project || '';
|
|
3503
|
+
if (!byProject.has(key)) byProject.set(key, []);
|
|
3504
|
+
byProject.get(key).push(s);
|
|
3505
|
+
}
|
|
3506
|
+
for (const [p, arr] of byProject) {
|
|
3507
|
+
body += p ? projectBlock(p, arr, true) : renderGroupSessions(arr, `__pinned_group_${group.id}__`);
|
|
3508
|
+
}
|
|
3423
3509
|
} else {
|
|
3424
3510
|
const loose = [];
|
|
3425
3511
|
const byProject = new Map();
|
|
@@ -3451,8 +3537,9 @@ function renderSessions() {
|
|
|
3451
3537
|
}
|
|
3452
3538
|
}
|
|
3453
3539
|
if (!body) body = '<div class="sg-empty">Drop a session or a project here</div>';
|
|
3540
|
+
const idAttr = group.transient ? '' : ` data-group-id="${escapeHtml(group.id)}"`;
|
|
3454
3541
|
out += sgHeaderHtml(group, countHtml(bucket));
|
|
3455
|
-
out += `<div class="session-group-sessions${collapsed ? ' collapsed' : ''}"
|
|
3542
|
+
out += `<div class="session-group-sessions${collapsed ? ' collapsed' : ''}"${idAttr}>${body}</div>`;
|
|
3456
3543
|
}
|
|
3457
3544
|
if (!out) return '';
|
|
3458
3545
|
return sectionHtml(SECTION_GROUPS, 'Groups', false, out);
|
|
@@ -3506,7 +3593,7 @@ function renderSessions() {
|
|
|
3506
3593
|
// The section header is rendered even when nothing is left under it: it is the drop target
|
|
3507
3594
|
// for ungrouping. Its body is skipped while collapsed — nothing would be visible, and this is
|
|
3508
3595
|
// the whole project list, re-parsed on every live refresh.
|
|
3509
|
-
const sectioned = sessionGroups.length > 0;
|
|
3596
|
+
const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
|
|
3510
3597
|
let projectsHtml = '';
|
|
3511
3598
|
if (!sectioned || !collapsedProjectGroups.has(SECTION_PROJECTS)) {
|
|
3512
3599
|
for (const [projectPath, projectSessions] of sortedGroups) {
|
|
@@ -3534,7 +3621,7 @@ function renderSessions() {
|
|
|
3534
3621
|
|
|
3535
3622
|
sessionsList.innerHTML = html;
|
|
3536
3623
|
} else {
|
|
3537
|
-
const sectioned = sessionGroups.length > 0;
|
|
3624
|
+
const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
|
|
3538
3625
|
const restHtml =
|
|
3539
3626
|
sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
|
|
3540
3627
|
const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml) : restHtml;
|
|
@@ -3581,6 +3668,11 @@ function renderSession() {
|
|
|
3581
3668
|
const session = sessions.find((s) => s.id === currentSessionId);
|
|
3582
3669
|
if (!session) return;
|
|
3583
3670
|
|
|
3671
|
+
// Opening the session, not opening a linked-docs surface: the card badge is the
|
|
3672
|
+
// first place a pad shows up, and it would otherwise stay at zero until the user
|
|
3673
|
+
// happened to open the info modal or zen panel.
|
|
3674
|
+
loadSessionPads(currentSessionId);
|
|
3675
|
+
|
|
3584
3676
|
const displayName =
|
|
3585
3677
|
session.customTitle || session.name || session.gitBranch || session.description || currentSessionId;
|
|
3586
3678
|
|
|
@@ -3960,7 +4052,7 @@ async function onColumnDrop(e) {
|
|
|
3960
4052
|
// windows the list), so membership is never garbage-collected on load.
|
|
3961
4053
|
const SESSION_GROUPS_KEY = 'sessionGroups';
|
|
3962
4054
|
const SG_ACTION_SELECTOR =
|
|
3963
|
-
'.session-pin-btn, .team-info-btn, .plan-indicator, .scratchpad-badge, .bookmarks-badge, .linked-docs-badge, .project-view-btn, .group-path-toggle, .pinned-ungroup-btn, .sg-action';
|
|
4055
|
+
'.session-pin-btn, .team-info-btn, .plan-indicator, .scratchpad-badge, .bookmarks-badge, .linked-docs-badge, .project-view-btn, .project-new-btn, .header-pad-btn, .group-path-toggle, .pinned-ungroup-btn, .sg-action';
|
|
3964
4056
|
|
|
3965
4057
|
// Collapse state shares the existing `collapsedGroups` key; this namespace keeps it from
|
|
3966
4058
|
// colliding with a project path or the __ungrouped__ / __pinned_* sentinels.
|
|
@@ -3995,14 +4087,17 @@ function loadSessionGroups() {
|
|
|
3995
4087
|
return out;
|
|
3996
4088
|
}),
|
|
3997
4089
|
}));
|
|
4090
|
+
const released = JSON.parse(store.getItem(SESSION_GROUPS_KEY) || 'null')?.released;
|
|
4091
|
+
sgReleased = new Set(Array.isArray(released) ? released.filter((id) => typeof id === 'string') : []);
|
|
3998
4092
|
} catch (_) {
|
|
3999
4093
|
sessionGroups = [];
|
|
4094
|
+
sgReleased = new Set();
|
|
4000
4095
|
}
|
|
4001
4096
|
}
|
|
4002
4097
|
|
|
4003
4098
|
function persistSessionGroups() {
|
|
4004
4099
|
try {
|
|
4005
|
-
store.setItem(SESSION_GROUPS_KEY, JSON.stringify({ version: 1, groups: sessionGroups }));
|
|
4100
|
+
store.setItem(SESSION_GROUPS_KEY, JSON.stringify({ version: 1, groups: sessionGroups, released: [...sgReleased] }));
|
|
4006
4101
|
} catch (_) {}
|
|
4007
4102
|
}
|
|
4008
4103
|
|
|
@@ -4016,9 +4111,51 @@ function sgGroupOf(type, ref) {
|
|
|
4016
4111
|
|
|
4017
4112
|
// An individually placed session wins over the placement of its project.
|
|
4018
4113
|
function sgGroupForSession(session) {
|
|
4114
|
+
return sgUserGroupFor(session) || sgDispatchGroupFor(session);
|
|
4115
|
+
}
|
|
4116
|
+
|
|
4117
|
+
function sgUserGroupFor(session) {
|
|
4019
4118
|
return sgGroupOf('session', session.id) || (session.project ? sgGroupOf('project', session.project) : null);
|
|
4020
4119
|
}
|
|
4021
4120
|
|
|
4121
|
+
// Transient groups come from the server (`dispatch start --group`) and go when their sessions
|
|
4122
|
+
// end. They are never written to localStorage; the objects are cached only so collapse state,
|
|
4123
|
+
// keyed by id, survives a re-render.
|
|
4124
|
+
const transientGroups = new Map();
|
|
4125
|
+
|
|
4126
|
+
function sgTransientGroup(name) {
|
|
4127
|
+
let group = transientGroups.get(name);
|
|
4128
|
+
if (!group) {
|
|
4129
|
+
group = { id: `t_${name}`, name, transient: true };
|
|
4130
|
+
transientGroups.set(name, group);
|
|
4131
|
+
}
|
|
4132
|
+
return group;
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
// A user group with the same name takes the session in, so Keep does not split a group in two.
|
|
4136
|
+
// With no group of its own, a started session follows its starter into the starter's named group.
|
|
4137
|
+
function sgDispatchGroupFor(session) {
|
|
4138
|
+
if (sgReleased.has(session.id)) return null;
|
|
4139
|
+
const name = session.dispatchGroup;
|
|
4140
|
+
if (name) return sessionGroups.find((g) => g.name.toLowerCase() === name) || sgTransientGroup(name);
|
|
4141
|
+
const starter = session.startedBy && sessions.find((s) => s.id === session.startedBy);
|
|
4142
|
+
return starter ? sgUserGroupFor(starter) : null;
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
function sgKeepTransient(name) {
|
|
4146
|
+
const group = sgTransientGroup(name);
|
|
4147
|
+
const members = sessions.filter((s) => sgGroupForSession(s) === group);
|
|
4148
|
+
const kept = sgCreateGroup(name);
|
|
4149
|
+
for (const s of members) kept.members.push({ type: 'session', ref: s.id });
|
|
4150
|
+
persistSessionGroups();
|
|
4151
|
+
}
|
|
4152
|
+
|
|
4153
|
+
function sgDeleteTransient(name) {
|
|
4154
|
+
const group = sgTransientGroup(name);
|
|
4155
|
+
for (const s of sessions) if (sgGroupForSession(s) === group) sgReleased.add(s.id);
|
|
4156
|
+
persistSessionGroups();
|
|
4157
|
+
}
|
|
4158
|
+
|
|
4022
4159
|
function sgDetach(type, ref) {
|
|
4023
4160
|
for (const g of sessionGroups) {
|
|
4024
4161
|
g.members = g.members.filter((m) => !(m.type === type && m.ref === ref));
|
|
@@ -4115,19 +4252,35 @@ function sgSearchMatchIds(query) {
|
|
|
4115
4252
|
return ids;
|
|
4116
4253
|
}
|
|
4117
4254
|
|
|
4255
|
+
const PAD_SVG =
|
|
4256
|
+
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>';
|
|
4257
|
+
|
|
4258
|
+
function headerPadBtnHtml(key, label) {
|
|
4259
|
+
const has = _hasScratchpad(key);
|
|
4260
|
+
return `<span class="header-pad-btn${has ? ' has-pad' : ''}" onclick="event.stopPropagation(); showScratchpad('${escAttrJs(key)}')" title="${has ? 'Open' : 'New'} scratchpad for ${escapeHtml(label)} (N)">${PAD_SVG}</span>`;
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4118
4263
|
function sgHeaderHtml(group, countHtml) {
|
|
4119
4264
|
const collapsed = collapsedProjectGroups.has(sgKey(group.id));
|
|
4120
4265
|
const editing = sgEditingId === group.id;
|
|
4121
4266
|
const nameHtml = editing
|
|
4122
4267
|
? `<input class="sg-name-input" type="text" value="${escapeHtml(group.name)}" aria-label="Group name" spellcheck="false">`
|
|
4123
4268
|
: `<span class="group-name">${escapeHtml(group.name)}</span>`;
|
|
4269
|
+
const attrs = group.transient
|
|
4270
|
+
? `class="session-group-header sg-transient${collapsed ? ' collapsed' : ''}" data-transient-group="${escapeHtml(group.name)}" title="${escapeHtml(group.name)} — goes when its sessions end"`
|
|
4271
|
+
: `class="session-group-header${collapsed ? ' collapsed' : ''}" draggable="${editing ? 'false' : 'true'}" data-group-id="${escapeHtml(group.id)}" title="${escapeHtml(group.name)} — drop sessions or projects here"`;
|
|
4272
|
+
const actions = group.transient
|
|
4273
|
+
? `<span class="sg-action sg-keep" title="Keep this group after its sessions end">Keep</span>
|
|
4274
|
+
<span class="sg-action sg-delete" title="Delete group (sessions return to Projects)">×</span>`
|
|
4275
|
+
: `${headerPadBtnHtml(_groupScratchpadKey(group.id), group.name)}
|
|
4276
|
+
<span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></span>
|
|
4277
|
+
<span class="sg-action sg-delete" title="Delete group (members return to Projects)">×</span>`;
|
|
4124
4278
|
return `
|
|
4125
|
-
<div
|
|
4279
|
+
<div ${attrs} data-group-path="${escapeHtml(sgKey(group.id))}">
|
|
4126
4280
|
${groupChevronSvg()}
|
|
4127
4281
|
${nameHtml}
|
|
4282
|
+
${actions}
|
|
4128
4283
|
${countHtml}
|
|
4129
|
-
<span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></span>
|
|
4130
|
-
<span class="sg-action sg-delete" title="Delete group (members return to Projects)">×</span>
|
|
4131
4284
|
</div>`;
|
|
4132
4285
|
}
|
|
4133
4286
|
|
|
@@ -4204,8 +4357,9 @@ function sgDropZone(target) {
|
|
|
4204
4357
|
? { zone, groupId: zone.dataset.groupId }
|
|
4205
4358
|
: null;
|
|
4206
4359
|
}
|
|
4207
|
-
if (zone.classList.contains('sg-ungroup-zone')) return dragGroup ? { zone } : null;
|
|
4208
4360
|
const dragSession = sgDrag.kind === 'session' ? sessions.find((x) => x.id === sgDrag.ref) : null;
|
|
4361
|
+
const releasable = !!dragSession && !sgUserGroupFor(dragSession) && !!sgDispatchGroupFor(dragSession);
|
|
4362
|
+
if (zone.classList.contains('sg-ungroup-zone')) return dragGroup || releasable ? { zone } : null;
|
|
4209
4363
|
const fromGroup = dragSession ? sgGroupForSession(dragSession) : dragGroup;
|
|
4210
4364
|
const fromGroupId = fromGroup?.id || null;
|
|
4211
4365
|
// A drop that lands on the host the session already has changes nothing, and highlighting it
|
|
@@ -4224,7 +4378,8 @@ function sgDropZone(target) {
|
|
|
4224
4378
|
const targetSession = sessions.find((s) => s.id === targetSessionId);
|
|
4225
4379
|
const targetGroup = targetSession ? sgGroupForSession(targetSession) : null;
|
|
4226
4380
|
const targetGroupId = targetGroup?.id || null;
|
|
4227
|
-
|
|
4381
|
+
// A transient group holds no members to reorder or place under; a drop there pairs instead.
|
|
4382
|
+
if (targetGroup && !targetGroup.transient) {
|
|
4228
4383
|
// Stacking onto a card adopts that card's placement, project block included.
|
|
4229
4384
|
const targetHost = sgHostOf(targetGroup, targetSession);
|
|
4230
4385
|
if (targetHost) {
|
|
@@ -4255,7 +4410,7 @@ function sgDropZone(target) {
|
|
|
4255
4410
|
return fromHost === path && fromGroupId === pathGroup.id ? null : { zone, groupId: pathGroup.id, under: path };
|
|
4256
4411
|
}
|
|
4257
4412
|
// Stacking a session onto its own project block would only fold it back where it sits today.
|
|
4258
|
-
if (dragSession?.project === path) return null;
|
|
4413
|
+
if (dragSession?.project === path) return releasable ? { zone } : null;
|
|
4259
4414
|
return { zone, pairWith: path };
|
|
4260
4415
|
}
|
|
4261
4416
|
|
|
@@ -4316,6 +4471,7 @@ function sgOnDrop(e) {
|
|
|
4316
4471
|
else sgAssign(hit.groupId, drag.kind, drag.ref, { under: hit.under, loose: hit.loose });
|
|
4317
4472
|
} else {
|
|
4318
4473
|
sgDetach(drag.kind, drag.ref);
|
|
4474
|
+
if (drag.kind === 'session') sgReleased.add(drag.ref);
|
|
4319
4475
|
persistSessionGroups();
|
|
4320
4476
|
}
|
|
4321
4477
|
sgFinishDrag();
|
|
@@ -5218,6 +5374,7 @@ const SHORTCUT_PAIRS = [
|
|
|
5218
5374
|
{ keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
5219
5375
|
{ keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
|
|
5220
5376
|
{ keys: ['J', 'K'], label: 'Previous / next message in detail' },
|
|
5377
|
+
{ keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt' },
|
|
5221
5378
|
],
|
|
5222
5379
|
},
|
|
5223
5380
|
],
|
|
@@ -5227,7 +5384,7 @@ const SHORTCUT_PAIRS = [
|
|
|
5227
5384
|
rows: [
|
|
5228
5385
|
{ keys: ['Enter'], label: 'Toggle task detail panel' },
|
|
5229
5386
|
{ keys: ['D'], label: 'Delete selected task' },
|
|
5230
|
-
{ keys: ['N'], label: 'Toggle scratchpad' },
|
|
5387
|
+
{ keys: ['N'], label: 'Toggle scratchpad (in the sidebar: the item under the cursor)' },
|
|
5231
5388
|
{ keys: ['R'], label: 'Refresh data' },
|
|
5232
5389
|
{ keys: ['Esc'], label: 'Close panel / clear selection' },
|
|
5233
5390
|
],
|
|
@@ -5267,6 +5424,25 @@ const SHORTCUT_PAIRS = [
|
|
|
5267
5424
|
],
|
|
5268
5425
|
},
|
|
5269
5426
|
],
|
|
5427
|
+
[
|
|
5428
|
+
{
|
|
5429
|
+
title: 'Terminal',
|
|
5430
|
+
rows: [
|
|
5431
|
+
{ keys: ['Ctrl', '`'], combo: true, label: 'Show / hide terminal' },
|
|
5432
|
+
{ keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
|
|
5433
|
+
{ keys: ['Alt', 'Shift', '`'], combo: true, label: 'End and close terminal' },
|
|
5434
|
+
{ keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
|
|
5435
|
+
],
|
|
5436
|
+
},
|
|
5437
|
+
{
|
|
5438
|
+
title: 'Sessions',
|
|
5439
|
+
rows: [
|
|
5440
|
+
{ keys: ['Ctrl', 'Alt', 'N'], combo: true, label: 'New session' },
|
|
5441
|
+
{ keys: ['Ctrl', 'Alt', 'R'], combo: true, label: 'Resume session (claude -r)' },
|
|
5442
|
+
{ keys: ['Ctrl', 'Alt', 'S'], combo: true, label: 'Swap to previous session' },
|
|
5443
|
+
],
|
|
5444
|
+
},
|
|
5445
|
+
],
|
|
5270
5446
|
];
|
|
5271
5447
|
|
|
5272
5448
|
const EMPTY_GROUP = { title: '', rows: [] };
|
|
@@ -5335,18 +5511,52 @@ const _scratchpadCharcount = document.getElementById('scratchpad-charcount');
|
|
|
5335
5511
|
|
|
5336
5512
|
let _scratchpadKeyOverride = null;
|
|
5337
5513
|
|
|
5514
|
+
const SESSION_PAD_PREFIX = 'scratchpad-';
|
|
5515
|
+
const PROJECT_PAD_PREFIX = `${SESSION_PAD_PREFIX}project:`;
|
|
5516
|
+
const GROUP_PAD_PREFIX = `${SESSION_PAD_PREFIX}group:`;
|
|
5517
|
+
|
|
5338
5518
|
function _sessionScratchpadKey(sessionId) {
|
|
5339
|
-
return
|
|
5519
|
+
return SESSION_PAD_PREFIX + sessionId;
|
|
5520
|
+
}
|
|
5521
|
+
|
|
5522
|
+
function _projectScratchpadKey(projectPath) {
|
|
5523
|
+
return PROJECT_PAD_PREFIX + projectPath;
|
|
5524
|
+
}
|
|
5525
|
+
|
|
5526
|
+
function _groupScratchpadKey(groupId) {
|
|
5527
|
+
return GROUP_PAD_PREFIX + groupId;
|
|
5528
|
+
}
|
|
5529
|
+
|
|
5530
|
+
// Every kind shares the session prefix, so the longer prefixes are tested first.
|
|
5531
|
+
function _parsePadKey(key) {
|
|
5532
|
+
for (const [kind, prefix] of [
|
|
5533
|
+
['group', GROUP_PAD_PREFIX],
|
|
5534
|
+
['project', PROJECT_PAD_PREFIX],
|
|
5535
|
+
['session', SESSION_PAD_PREFIX],
|
|
5536
|
+
]) {
|
|
5537
|
+
if (key.startsWith(prefix)) return { kind, id: key.slice(prefix.length) };
|
|
5538
|
+
}
|
|
5539
|
+
return null;
|
|
5340
5540
|
}
|
|
5341
5541
|
|
|
5342
|
-
function
|
|
5343
|
-
return
|
|
5542
|
+
function _hasScratchpad(key) {
|
|
5543
|
+
return !!(store.getItem(key) || '').trim();
|
|
5344
5544
|
}
|
|
5345
5545
|
|
|
5346
5546
|
function _scratchpadKey() {
|
|
5347
5547
|
if (_scratchpadKeyOverride) return _scratchpadKeyOverride;
|
|
5348
5548
|
if (currentSessionId) return _sessionScratchpadKey(currentSessionId);
|
|
5349
|
-
if (currentProjectPath) return
|
|
5549
|
+
if (currentProjectPath) return _projectScratchpadKey(currentProjectPath);
|
|
5550
|
+
return null;
|
|
5551
|
+
}
|
|
5552
|
+
|
|
5553
|
+
function _scratchpadKeyOf(el) {
|
|
5554
|
+
if (!el) return null;
|
|
5555
|
+
if (el.dataset.sessionId) return _sessionScratchpadKey(el.dataset.sessionId);
|
|
5556
|
+
if (el.classList.contains('session-group-header')) return _groupScratchpadKey(el.dataset.groupId);
|
|
5557
|
+
if (el.classList.contains('project-group-header') && el.dataset.projectPath) {
|
|
5558
|
+
return _projectScratchpadKey(el.dataset.projectPath);
|
|
5559
|
+
}
|
|
5350
5560
|
return null;
|
|
5351
5561
|
}
|
|
5352
5562
|
|
|
@@ -5387,14 +5597,14 @@ function saveScratchpad() {
|
|
|
5387
5597
|
const key = _scratchpadKey();
|
|
5388
5598
|
if (!key) return;
|
|
5389
5599
|
const val = _scratchpadTextarea.value;
|
|
5390
|
-
const had =
|
|
5600
|
+
const had = _hasScratchpad(key);
|
|
5391
5601
|
const has = !!val.trim();
|
|
5392
5602
|
if (has) {
|
|
5393
5603
|
store.setItem(key, val);
|
|
5394
5604
|
} else {
|
|
5395
5605
|
store.removeItem(key);
|
|
5396
5606
|
}
|
|
5397
|
-
if (had !== has
|
|
5607
|
+
if (had !== has) {
|
|
5398
5608
|
renderSessions();
|
|
5399
5609
|
}
|
|
5400
5610
|
}
|
|
@@ -5408,6 +5618,30 @@ _scratchpadTextarea.addEventListener('input', () => {
|
|
|
5408
5618
|
}, 500);
|
|
5409
5619
|
});
|
|
5410
5620
|
|
|
5621
|
+
// Bound on the textarea: the global handler returns early on TEXTAREA targets.
|
|
5622
|
+
_scratchpadTextarea.addEventListener('keydown', (e) => {
|
|
5623
|
+
if (e.key === 'Escape') closeScratchpad();
|
|
5624
|
+
});
|
|
5625
|
+
|
|
5626
|
+
// Vimium eats Escape inside a text field and only blurs it, so the page never sees the key.
|
|
5627
|
+
// A blur that no click in the modal caused, while the window keeps focus, is that Escape.
|
|
5628
|
+
let _scratchpadPointerDown = false;
|
|
5629
|
+
_scratchpadModal.addEventListener('mousedown', () => {
|
|
5630
|
+
_scratchpadPointerDown = true;
|
|
5631
|
+
});
|
|
5632
|
+
document.addEventListener('mouseup', () => {
|
|
5633
|
+
_scratchpadPointerDown = false;
|
|
5634
|
+
});
|
|
5635
|
+
_scratchpadTextarea.addEventListener('blur', (e) => {
|
|
5636
|
+
if (
|
|
5637
|
+
!e.relatedTarget &&
|
|
5638
|
+
!_scratchpadPointerDown &&
|
|
5639
|
+
_scratchpadModal.classList.contains('visible') &&
|
|
5640
|
+
document.hasFocus()
|
|
5641
|
+
)
|
|
5642
|
+
closeScratchpad();
|
|
5643
|
+
});
|
|
5644
|
+
|
|
5411
5645
|
//#endregion
|
|
5412
5646
|
|
|
5413
5647
|
//#region STORAGE_MANAGER
|
|
@@ -5624,30 +5858,38 @@ function _storageUnpinMessage(sessionId, pinId) {
|
|
|
5624
5858
|
function _renderStorageScratchpads() {
|
|
5625
5859
|
const allItems = [];
|
|
5626
5860
|
for (const key of store.keys()) {
|
|
5627
|
-
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
const id = isProject ? key.slice('scratchpad-project:'.length) : key.slice('scratchpad-'.length);
|
|
5631
|
-
allItems.push({ key, id, isProject, chars: val.length });
|
|
5861
|
+
const parsed = _parsePadKey(key);
|
|
5862
|
+
if (!parsed) continue;
|
|
5863
|
+
allItems.push({ key, ...parsed, chars: (store.getItem(key) || '').length });
|
|
5632
5864
|
}
|
|
5633
5865
|
if (!allItems.length) return '<div class="storage-empty">No scratchpads</div>';
|
|
5634
5866
|
|
|
5635
|
-
const
|
|
5636
|
-
const
|
|
5867
|
+
const groupItems = allItems.filter((i) => i.kind === 'group');
|
|
5868
|
+
const projectItems = allItems.filter((i) => i.kind === 'project');
|
|
5869
|
+
const sessionItems = allItems.filter((i) => i.kind === 'session');
|
|
5637
5870
|
const sessionIds = sessionItems.map((i) => i.id);
|
|
5638
5871
|
const { groups: projectGroups, orphans } = _groupByProject(sessionIds);
|
|
5639
5872
|
const scratchBySession = new Map(sessionItems.map((i) => [i.id, i]));
|
|
5640
5873
|
|
|
5641
5874
|
function renderScratchItem(item) {
|
|
5642
|
-
const session = !item.isProject ? sessions.find((s) => s.id === item.id) : null;
|
|
5643
|
-
const typeBadge = item.isProject
|
|
5644
|
-
? '<span class="storage-item-badge">project</span>'
|
|
5645
|
-
: '<span class="storage-item-badge">session</span>';
|
|
5646
5875
|
const jsKey = escAttrJs(item.key);
|
|
5647
|
-
|
|
5876
|
+
let label;
|
|
5877
|
+
switch (item.kind) {
|
|
5878
|
+
case 'group':
|
|
5879
|
+
label = escapeHtml(sgGroupById(item.id)?.name ?? `${item.id} (deleted)`);
|
|
5880
|
+
break;
|
|
5881
|
+
case 'project':
|
|
5882
|
+
label = escapeHtml(_projectLabel(item.id));
|
|
5883
|
+
break;
|
|
5884
|
+
default:
|
|
5885
|
+
label = _sessionLabel(
|
|
5886
|
+
sessions.find((s) => s.id === item.id),
|
|
5887
|
+
item.id,
|
|
5888
|
+
);
|
|
5889
|
+
}
|
|
5648
5890
|
return `<div class="storage-item">
|
|
5649
5891
|
<span class="storage-item-id" title="${escapeHtml(item.id)}">${label}</span>
|
|
5650
|
-
|
|
5892
|
+
<span class="storage-item-badge">${item.kind}</span>
|
|
5651
5893
|
<span class="storage-item-meta">${item.chars} chars</span>
|
|
5652
5894
|
<div class="storage-item-actions">
|
|
5653
5895
|
<button onclick="_storagePreviewScratchpad('${jsKey}')">View</button>
|
|
@@ -5658,6 +5900,14 @@ function _renderStorageScratchpads() {
|
|
|
5658
5900
|
|
|
5659
5901
|
let html = '';
|
|
5660
5902
|
|
|
5903
|
+
if (groupItems.length) {
|
|
5904
|
+
html += _renderProjectGroup(
|
|
5905
|
+
'Group Scratchpads',
|
|
5906
|
+
`${groupItems.length}`,
|
|
5907
|
+
groupItems.map(renderScratchItem).join(''),
|
|
5908
|
+
);
|
|
5909
|
+
}
|
|
5910
|
+
|
|
5661
5911
|
if (projectItems.length) {
|
|
5662
5912
|
html += _renderProjectGroup(
|
|
5663
5913
|
'Project Scratchpads',
|
|
@@ -5717,12 +5967,8 @@ function _renderStorageLinkedDocs() {
|
|
|
5717
5967
|
const entries = [];
|
|
5718
5968
|
for (const key of store.keys()) {
|
|
5719
5969
|
if (!key.startsWith(PREVIEW_STORAGE_PREFIX)) continue;
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
if (Array.isArray(arr) && arr.length) {
|
|
5723
|
-
entries.push({ sessionId: key.slice(PREVIEW_STORAGE_PREFIX.length), paths: arr });
|
|
5724
|
-
}
|
|
5725
|
-
} catch {}
|
|
5970
|
+
const paths = readStoredList(key);
|
|
5971
|
+
if (paths.length) entries.push({ sessionId: key.slice(PREVIEW_STORAGE_PREFIX.length), paths });
|
|
5726
5972
|
}
|
|
5727
5973
|
if (!entries.length) return '<div class="storage-empty">No linked documents</div>';
|
|
5728
5974
|
|
|
@@ -5783,6 +6029,8 @@ function _storageUnlinkDoc(sessionId, path) {
|
|
|
5783
6029
|
|
|
5784
6030
|
function _storageClearLinkedDocs(sessionId) {
|
|
5785
6031
|
store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
6032
|
+
// The auto-link record deliberately survives: clearing is unlinking every row at
|
|
6033
|
+
// once, and an unlinked pad must not come back on the next read of the transcript.
|
|
5786
6034
|
afterLinkedDocsChanged(sessionId);
|
|
5787
6035
|
}
|
|
5788
6036
|
|
|
@@ -5793,12 +6041,17 @@ function _findOrphanedKeys() {
|
|
|
5793
6041
|
for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
|
|
5794
6042
|
for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
|
|
5795
6043
|
for (const key of store.keys()) {
|
|
5796
|
-
|
|
6044
|
+
const pad = _parsePadKey(key);
|
|
6045
|
+
if (pad?.kind === 'group') {
|
|
6046
|
+
if (!sgGroupById(pad.id)) orphaned.push(key);
|
|
6047
|
+
} else if (key.startsWith('pinned-messages-')) {
|
|
5797
6048
|
if (!known.has(key.slice('pinned-messages-'.length))) orphaned.push(key);
|
|
5798
|
-
} else if (
|
|
5799
|
-
if (!known.has(
|
|
6049
|
+
} else if (pad?.kind === 'session') {
|
|
6050
|
+
if (!known.has(pad.id)) orphaned.push(key);
|
|
5800
6051
|
} else if (key.startsWith(PREVIEW_STORAGE_PREFIX)) {
|
|
5801
6052
|
if (!known.has(key.slice(PREVIEW_STORAGE_PREFIX.length))) orphaned.push(key);
|
|
6053
|
+
} else if (key.startsWith(PAD_LINKED_PREFIX)) {
|
|
6054
|
+
if (!known.has(key.slice(PAD_LINKED_PREFIX.length))) orphaned.push(key);
|
|
5802
6055
|
}
|
|
5803
6056
|
}
|
|
5804
6057
|
return orphaned;
|
|
@@ -5869,6 +6122,8 @@ const MODAL_CLOSERS = {
|
|
|
5869
6122
|
'agent-modal': () => closeAgentModal(),
|
|
5870
6123
|
'help-modal': () => closeHelpModal(),
|
|
5871
6124
|
'session-picker-modal': () => closeSessionPicker(),
|
|
6125
|
+
'terminal-manager-modal': () => closeTerminalManager(),
|
|
6126
|
+
'new-session-modal': () => closeNewSession(),
|
|
5872
6127
|
};
|
|
5873
6128
|
|
|
5874
6129
|
document.addEventListener('keydown', (e) => {
|
|
@@ -5884,6 +6139,35 @@ document.addEventListener('keydown', (e) => {
|
|
|
5884
6139
|
}
|
|
5885
6140
|
}
|
|
5886
6141
|
|
|
6142
|
+
// Above the text-field guard: xterm's input is a textarea, and the toggle must work from it.
|
|
6143
|
+
const terminalPrompt = document.getElementById('terminal-prompt');
|
|
6144
|
+
if (terminalPrompt.contains(e.target)) {
|
|
6145
|
+
if (e.key === 'Escape') {
|
|
6146
|
+
e.preventDefault();
|
|
6147
|
+
toggleTerminal();
|
|
6148
|
+
return;
|
|
6149
|
+
}
|
|
6150
|
+
if (e.key === 'Enter' || e.key === ' ') return;
|
|
6151
|
+
const step = matchKey(e, 'ArrowLeft', 'ArrowUp', 'KeyH', 'KeyK')
|
|
6152
|
+
? -1
|
|
6153
|
+
: matchKey(e, 'ArrowRight', 'ArrowDown', 'KeyL', 'KeyJ')
|
|
6154
|
+
? 1
|
|
6155
|
+
: 0;
|
|
6156
|
+
if (step) {
|
|
6157
|
+
e.preventDefault();
|
|
6158
|
+
const buttons = [...terminalPrompt.querySelectorAll('button')];
|
|
6159
|
+
const i = buttons.indexOf(e.target);
|
|
6160
|
+
buttons[(i + step + buttons.length) % buttons.length]?.focus();
|
|
6161
|
+
return;
|
|
6162
|
+
}
|
|
6163
|
+
}
|
|
6164
|
+
const terminalAction = terminalShortcut(e);
|
|
6165
|
+
if (terminalAction) {
|
|
6166
|
+
e.preventDefault();
|
|
6167
|
+
terminalAction();
|
|
6168
|
+
return;
|
|
6169
|
+
}
|
|
6170
|
+
|
|
5887
6171
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
|
|
5888
6172
|
return;
|
|
5889
6173
|
}
|
|
@@ -5913,7 +6197,16 @@ document.addEventListener('keydown', (e) => {
|
|
|
5913
6197
|
e.preventDefault();
|
|
5914
6198
|
closeMsgDetailModal();
|
|
5915
6199
|
} else if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
5916
|
-
if (
|
|
6200
|
+
if (
|
|
6201
|
+
e.key === 'Enter' &&
|
|
6202
|
+
(e.ctrlKey || e.metaKey) &&
|
|
6203
|
+
currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX &&
|
|
6204
|
+
isWaitingAnswerable() &&
|
|
6205
|
+
currentWaiting.kind !== 'question'
|
|
6206
|
+
) {
|
|
6207
|
+
e.preventDefault();
|
|
6208
|
+
respondWaiting({ behavior: 'allow' });
|
|
6209
|
+
} else if (matchKey(e, 'ArrowDown', 'KeyJ')) {
|
|
5917
6210
|
e.preventDefault();
|
|
5918
6211
|
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
5919
6212
|
msgDetailFollowLatest = true;
|
|
@@ -5997,6 +6290,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
5997
6290
|
// Tab toggles focus zone
|
|
5998
6291
|
if (e.key === 'Tab') {
|
|
5999
6292
|
e.preventDefault();
|
|
6293
|
+
// The terminal replaces the board, so it takes the board's place in the Tab cycle.
|
|
6294
|
+
if (wantsTerminal()) {
|
|
6295
|
+
if (focusZone === 'sidebar') focusTerminalPane();
|
|
6296
|
+
else leaveTerminalPane();
|
|
6297
|
+
return;
|
|
6298
|
+
}
|
|
6000
6299
|
if (focusZone === 'sidebar') {
|
|
6001
6300
|
const hasCards = document.querySelector('.task-card');
|
|
6002
6301
|
if (!hasCards) return;
|
|
@@ -6095,10 +6394,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
6095
6394
|
}
|
|
6096
6395
|
|
|
6097
6396
|
// Shared actions — work in both sidebar and board
|
|
6397
|
+
const cursorEl = focusZone === 'sidebar' ? sessionsList.querySelector('.kb-selected') : null;
|
|
6098
6398
|
const contextSid =
|
|
6099
|
-
focusZone === 'sidebar'
|
|
6100
|
-
? sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId
|
|
6101
|
-
: selectedSessionId || currentSessionId;
|
|
6399
|
+
focusZone === 'sidebar' ? cursorEl?.dataset.sessionId || currentSessionId : selectedSessionId || currentSessionId;
|
|
6102
6400
|
if (matchKey(e, 'KeyP') && !e.shiftKey) {
|
|
6103
6401
|
e.preventDefault();
|
|
6104
6402
|
if (contextSid) openPlanForSession(contextSid);
|
|
@@ -6111,7 +6409,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
6111
6409
|
}
|
|
6112
6410
|
if (matchKey(e, 'KeyN') && !e.shiftKey) {
|
|
6113
6411
|
e.preventDefault();
|
|
6114
|
-
|
|
6412
|
+
const cursorKey = _scratchpadKeyOf(cursorEl);
|
|
6413
|
+
if (cursorKey) showScratchpad(cursorKey);
|
|
6414
|
+
else toggleScratchpad();
|
|
6115
6415
|
return;
|
|
6116
6416
|
}
|
|
6117
6417
|
if (e.key === '$' && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
@@ -6206,27 +6506,40 @@ document.addEventListener('keydown', (e) => {
|
|
|
6206
6506
|
const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
|
|
6207
6507
|
let currentPreviewPath = null;
|
|
6208
6508
|
|
|
6209
|
-
function
|
|
6210
|
-
if (!sessionId) return [];
|
|
6509
|
+
function readStoredList(key) {
|
|
6211
6510
|
try {
|
|
6212
|
-
const
|
|
6213
|
-
const arr = raw ? JSON.parse(raw) : [];
|
|
6511
|
+
const arr = JSON.parse(store.getItem(key) || '[]');
|
|
6214
6512
|
return Array.isArray(arr) ? arr : [];
|
|
6215
6513
|
} catch {
|
|
6216
6514
|
return [];
|
|
6217
6515
|
}
|
|
6218
6516
|
}
|
|
6219
6517
|
|
|
6518
|
+
function getSessionPreviewPaths(sessionId) {
|
|
6519
|
+
return sessionId ? readStoredList(PREVIEW_STORAGE_PREFIX + sessionId) : [];
|
|
6520
|
+
}
|
|
6521
|
+
|
|
6522
|
+
// Windows reaches one file as both `C:\a\b` and `C:/a/b`, and the two sides of a link
|
|
6523
|
+
// disagree by construction: a pad path is built with `path.join`, a hand-typed one is
|
|
6524
|
+
// stored as it was typed. Rows are compared through here so the same file cannot be
|
|
6525
|
+
// listed twice — and lists written before this existed heal on the next link.
|
|
6526
|
+
function canonicalPath(filePath) {
|
|
6527
|
+
const slashed = String(filePath).replace(/\\/g, '/');
|
|
6528
|
+
return /^[A-Za-z]:/.test(slashed) ? slashed.toLowerCase() : slashed;
|
|
6529
|
+
}
|
|
6530
|
+
|
|
6220
6531
|
function addSessionPreviewPath(sessionId, filePath) {
|
|
6221
6532
|
if (!sessionId || !filePath) return;
|
|
6222
|
-
const
|
|
6533
|
+
const key = canonicalPath(filePath);
|
|
6534
|
+
const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
|
|
6223
6535
|
paths.unshift(filePath);
|
|
6224
6536
|
store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths.slice(0, 20)));
|
|
6225
6537
|
}
|
|
6226
6538
|
|
|
6227
6539
|
function removeSessionPreviewPath(sessionId, filePath) {
|
|
6228
6540
|
if (!sessionId) return;
|
|
6229
|
-
const
|
|
6541
|
+
const key = canonicalPath(filePath);
|
|
6542
|
+
const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
|
|
6230
6543
|
if (paths.length) store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths));
|
|
6231
6544
|
else store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
6232
6545
|
}
|
|
@@ -6296,23 +6609,48 @@ function bindPreviewRelativeLinks(bodyEl) {
|
|
|
6296
6609
|
const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
|
|
6297
6610
|
if (isAbsoluteUrl) return;
|
|
6298
6611
|
const cleanHref = href.replace(/#.*$/, '');
|
|
6299
|
-
if (!isPreviewablePath(cleanHref)) return;
|
|
6300
6612
|
e.preventDefault();
|
|
6301
|
-
openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath);
|
|
6613
|
+
openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath, openFileInEditor);
|
|
6302
6614
|
});
|
|
6303
6615
|
bodyEl.dataset.relLinkBound = '1';
|
|
6304
6616
|
}
|
|
6305
6617
|
|
|
6306
6618
|
// `kind` comes from the server, which is the only place that decides what is previewable.
|
|
6619
|
+
// hljs gets the extension as the language hint and `highlightAuto` is deliberately not
|
|
6620
|
+
// the fallback: on a config or data file it guesses wildly, and wrong colors read as a
|
|
6621
|
+
// broken file. Above the size cap it is skipped outright — it is O(size) with heavy
|
|
6622
|
+
// backtracking on the main thread, and .jsonl/.log files in a scratchpad reach megabytes.
|
|
6623
|
+
const HLJS_MAX_CHARS = 256 * 1024;
|
|
6624
|
+
|
|
6625
|
+
function renderSourcePreview(filePath, content) {
|
|
6626
|
+
const ext = (filePath.split('.').pop() || '').toLowerCase();
|
|
6627
|
+
if (typeof hljs !== 'undefined' && content.length <= HLJS_MAX_CHARS && hljs.getLanguage(ext)) {
|
|
6628
|
+
try {
|
|
6629
|
+
const value = hljs.highlight(content, { language: ext, ignoreIllegals: true }).value;
|
|
6630
|
+
return `<pre class="preview-source"><code class="hljs language-${escapeHtml(ext)}">${value}</code></pre>`;
|
|
6631
|
+
} catch (_) {}
|
|
6632
|
+
}
|
|
6633
|
+
return `<pre class="preview-source"><code class="hljs">${escapeHtml(content)}</code></pre>`;
|
|
6634
|
+
}
|
|
6635
|
+
|
|
6307
6636
|
function openPreviewModal(filePath, content, kind) {
|
|
6308
6637
|
currentPreviewPath = filePath;
|
|
6309
|
-
|
|
6638
|
+
const fileName = filePath.split(/[\\/]/).pop();
|
|
6639
|
+
document.getElementById('preview-modal-title').textContent = fileName;
|
|
6310
6640
|
const bodyEl = document.getElementById('preview-modal-body');
|
|
6311
6641
|
const isHtml = kind === 'html';
|
|
6312
6642
|
document.querySelector('#preview-modal .modal').classList.toggle('preview-html', isHtml);
|
|
6313
6643
|
bindPreviewRelativeLinks(bodyEl);
|
|
6314
6644
|
if (isHtml) {
|
|
6315
6645
|
renderHtmlPreview(bodyEl, content);
|
|
6646
|
+
} else if (kind === 'text') {
|
|
6647
|
+
bodyEl.innerHTML = renderSourcePreview(filePath, content);
|
|
6648
|
+
} else if (kind === 'image') {
|
|
6649
|
+
const img = document.createElement('img');
|
|
6650
|
+
img.className = 'preview-image';
|
|
6651
|
+
img.src = `/api/preview/image?${new URLSearchParams({ path: filePath })}`;
|
|
6652
|
+
img.alt = fileName;
|
|
6653
|
+
bodyEl.replaceChildren(img);
|
|
6316
6654
|
} else {
|
|
6317
6655
|
const { fm, body } = splitFrontmatter(content);
|
|
6318
6656
|
bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
|
|
@@ -6324,7 +6662,8 @@ function openPreviewModal(filePath, content, kind) {
|
|
|
6324
6662
|
|
|
6325
6663
|
function isPreviewLinkedToCurrentSession() {
|
|
6326
6664
|
if (!currentPreviewPath || !currentSessionId) return false;
|
|
6327
|
-
|
|
6665
|
+
const key = canonicalPath(currentPreviewPath);
|
|
6666
|
+
return getSessionPreviewPaths(currentSessionId).some((p) => canonicalPath(p) === key);
|
|
6328
6667
|
}
|
|
6329
6668
|
|
|
6330
6669
|
function updatePreviewLinkBtn() {
|
|
@@ -6375,13 +6714,20 @@ function closePreviewModal() {
|
|
|
6375
6714
|
currentPreviewPath = null;
|
|
6376
6715
|
}
|
|
6377
6716
|
|
|
6717
|
+
function openFileInEditor(filePath) {
|
|
6718
|
+
postAndToast('/api/open-in-editor', { file: filePath }, 'in editor');
|
|
6719
|
+
}
|
|
6720
|
+
|
|
6378
6721
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
6379
6722
|
function openPreviewInEditor() {
|
|
6380
6723
|
if (!currentPreviewPath) return;
|
|
6381
|
-
|
|
6724
|
+
openFileInEditor(currentPreviewPath);
|
|
6382
6725
|
}
|
|
6383
6726
|
|
|
6384
|
-
|
|
6727
|
+
// The server is the only place that knows what it can render, so a null `kind` is its
|
|
6728
|
+
// answer to "not previewable" rather than a failure: callers that have a fallback
|
|
6729
|
+
// destination pass `onUnsupported` instead of keeping a copy of the extension list here.
|
|
6730
|
+
async function openPreviewByPath(filePath, base, onUnsupported) {
|
|
6385
6731
|
if (!filePath) return;
|
|
6386
6732
|
try {
|
|
6387
6733
|
const qs = new URLSearchParams({ path: filePath });
|
|
@@ -6392,6 +6738,15 @@ async function openPreviewByPath(filePath, base) {
|
|
|
6392
6738
|
return;
|
|
6393
6739
|
}
|
|
6394
6740
|
const data = await r.json();
|
|
6741
|
+
if (data.exists === false) {
|
|
6742
|
+
showToast('File not found');
|
|
6743
|
+
return;
|
|
6744
|
+
}
|
|
6745
|
+
if (data.kind === null) {
|
|
6746
|
+
if (onUnsupported) onUnsupported(data.path);
|
|
6747
|
+
else showToast('Nothing to preview in this file');
|
|
6748
|
+
return;
|
|
6749
|
+
}
|
|
6395
6750
|
openPreviewModal(data.path, data.content, data.kind);
|
|
6396
6751
|
} catch {
|
|
6397
6752
|
showToast('Failed to load preview');
|
|
@@ -6445,10 +6800,69 @@ function getSessionBaseDir(sessionId) {
|
|
|
6445
6800
|
return s?.cwd || s?.project || '';
|
|
6446
6801
|
}
|
|
6447
6802
|
|
|
6448
|
-
// Linked paths are stored as bare strings, so
|
|
6449
|
-
//
|
|
6450
|
-
|
|
6451
|
-
|
|
6803
|
+
// Linked paths are stored as bare strings, so the row has to ask what each one is. The
|
|
6804
|
+
// server owns that answer — /api/file/resolve reports `kind` for any file, previewable
|
|
6805
|
+
// or not — and it is cached for the tab because a path's kind does not change under it.
|
|
6806
|
+
// A resolve that fails is cached as unpreviewable: the editor is the right destination
|
|
6807
|
+
// for a file the server cannot read either, and re-asking on every render of a panel the
|
|
6808
|
+
// SSE tick repaints would be a poll.
|
|
6809
|
+
const previewKindByPath = new Map();
|
|
6810
|
+
const kindResolveInFlight = new Set();
|
|
6811
|
+
|
|
6812
|
+
async function loadPreviewKinds(sessionId, paths) {
|
|
6813
|
+
const pending = paths.filter((p) => !previewKindByPath.has(p) && !kindResolveInFlight.has(p));
|
|
6814
|
+
if (!pending.length) return;
|
|
6815
|
+
for (const p of pending) kindResolveInFlight.add(p);
|
|
6816
|
+
try {
|
|
6817
|
+
await Promise.all(
|
|
6818
|
+
pending.map(async (p) => {
|
|
6819
|
+
let kind = null;
|
|
6820
|
+
try {
|
|
6821
|
+
const r = await fetch(`/api/file/resolve?path=${encodeURIComponent(p)}`);
|
|
6822
|
+
if (r.ok) kind = (await r.json()).kind;
|
|
6823
|
+
} catch (_) {}
|
|
6824
|
+
previewKindByPath.set(p, kind);
|
|
6825
|
+
}),
|
|
6826
|
+
);
|
|
6827
|
+
} finally {
|
|
6828
|
+
for (const p of pending) kindResolveInFlight.delete(p);
|
|
6829
|
+
}
|
|
6830
|
+
afterLinkedDocsChanged(sessionId);
|
|
6831
|
+
}
|
|
6832
|
+
|
|
6833
|
+
// A pad the session made with `scratch new` becomes an ordinary linked document.
|
|
6834
|
+
// The server derives the pads on every read, so this record of the ones already
|
|
6835
|
+
// linked is what stops the next read undoing an unlink.
|
|
6836
|
+
const PAD_LINKED_PREFIX = 'autolinked-pads-';
|
|
6837
|
+
const padsFetched = new Set();
|
|
6838
|
+
|
|
6839
|
+
function getOfferedPadPaths(sessionId) {
|
|
6840
|
+
return readStoredList(PAD_LINKED_PREFIX + sessionId);
|
|
6841
|
+
}
|
|
6842
|
+
|
|
6843
|
+
function setOfferedPadPaths(sessionId, paths) {
|
|
6844
|
+
store.setItem(PAD_LINKED_PREFIX + sessionId, JSON.stringify(paths));
|
|
6845
|
+
}
|
|
6846
|
+
|
|
6847
|
+
async function loadSessionPads(sessionId) {
|
|
6848
|
+
if (!sessionId || padsFetched.has(sessionId)) return;
|
|
6849
|
+
padsFetched.add(sessionId);
|
|
6850
|
+
let pads;
|
|
6851
|
+
try {
|
|
6852
|
+
const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/pads`);
|
|
6853
|
+
if (!r.ok) throw new Error(r.status);
|
|
6854
|
+
pads = (await r.json()).pads || [];
|
|
6855
|
+
} catch (_) {
|
|
6856
|
+
padsFetched.delete(sessionId);
|
|
6857
|
+
return;
|
|
6858
|
+
}
|
|
6859
|
+
const offered = new Set(getOfferedPadPaths(sessionId).map(canonicalPath));
|
|
6860
|
+
const fresh = pads.map((p) => p.path).filter((p) => !offered.has(canonicalPath(p)));
|
|
6861
|
+
if (!fresh.length) return;
|
|
6862
|
+
// Recorded before the link, so a render triggered by the link already sees a pad
|
|
6863
|
+
// that will not be offered again.
|
|
6864
|
+
setOfferedPadPaths(sessionId, [...offered, ...fresh]);
|
|
6865
|
+
for (const p of fresh) setSessionDocLink(sessionId, p, false);
|
|
6452
6866
|
}
|
|
6453
6867
|
|
|
6454
6868
|
// A scratchpad manifest opens in the external `scratch` viewer, not the editor —
|
|
@@ -6477,11 +6891,14 @@ const LINKED_DOC_OPENERS = {
|
|
|
6477
6891
|
},
|
|
6478
6892
|
};
|
|
6479
6893
|
|
|
6894
|
+
// The manifest is checked before previewability, not after: its `.json` is previewable
|
|
6895
|
+
// text, but the pad is what the user linked, so the viewer wins over the raw file.
|
|
6896
|
+
// Without the CLI there is nothing to launch, so it falls back to the editor.
|
|
6480
6897
|
function linkedDocOpener(p) {
|
|
6481
|
-
if (
|
|
6482
|
-
//
|
|
6483
|
-
|
|
6484
|
-
return 'editor';
|
|
6898
|
+
if (isScratchpadPath(p)) return appConfig.scratchAvailable ? 'scratch' : 'editor';
|
|
6899
|
+
// A path whose kind has not arrived yet renders as previewable, which is what the
|
|
6900
|
+
// click does anyway — openPreviewByPath falls back to the editor on a null kind.
|
|
6901
|
+
return previewKindByPath.get(p) === null ? 'editor' : 'preview';
|
|
6485
6902
|
}
|
|
6486
6903
|
|
|
6487
6904
|
// Every manifest is called scratchpad.json, so the pad's folder names it. Kept
|
|
@@ -6494,7 +6911,7 @@ function linkedDocLabel(p) {
|
|
|
6494
6911
|
function openLinkedDoc(p, baseDir) {
|
|
6495
6912
|
const opener = linkedDocOpener(p);
|
|
6496
6913
|
if (opener === 'preview') {
|
|
6497
|
-
openPreviewByPath(p, baseDir);
|
|
6914
|
+
openPreviewByPath(p, baseDir, openFileInEditor);
|
|
6498
6915
|
return;
|
|
6499
6916
|
}
|
|
6500
6917
|
const { url, body, toast } = LINKED_DOC_OPENERS[opener];
|
|
@@ -6503,6 +6920,9 @@ function openLinkedDoc(p, baseDir) {
|
|
|
6503
6920
|
|
|
6504
6921
|
function renderLinkedDocsHtml(sessionId) {
|
|
6505
6922
|
const paths = getSessionPreviewPaths(sessionId);
|
|
6923
|
+
// Kicked off from the render rather than the three call sites, so a new surface that
|
|
6924
|
+
// shows linked docs cannot forget it. Returns at once once every path is cached.
|
|
6925
|
+
loadPreviewKinds(sessionId, paths);
|
|
6506
6926
|
const baseDir = getSessionBaseDir(sessionId);
|
|
6507
6927
|
const items = paths
|
|
6508
6928
|
.map((p) => {
|
|
@@ -6538,11 +6958,41 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
6538
6958
|
</div>`;
|
|
6539
6959
|
}
|
|
6540
6960
|
|
|
6541
|
-
|
|
6542
|
-
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
|
|
6961
|
+
function paintSectionSlots(attr, sessionId, html) {
|
|
6962
|
+
for (const slot of document.querySelectorAll(`[${attr}="${CSS.escape(sessionId)}"]`)) {
|
|
6963
|
+
slot.innerHTML = html;
|
|
6964
|
+
}
|
|
6965
|
+
}
|
|
6966
|
+
|
|
6967
|
+
// A panel section whose data is not part of the session object arrives after the panel
|
|
6968
|
+
// is already on screen, so every one of them needs the same lifecycle: one fetch per
|
|
6969
|
+
// session at a time, and a patch of the slot — always rendered, empty or not — once the
|
|
6970
|
+
// data lands. `ttlMs` is for the sections the server does not cache itself; at 0 the
|
|
6971
|
+
// section re-asks on every render, which is right when the server holds the answer.
|
|
6972
|
+
function makeSectionLoader({ attr, endpoint, pick, innerHtml, ttlMs = 0 }) {
|
|
6973
|
+
const store = new Map();
|
|
6974
|
+
const inFlight = new Set();
|
|
6975
|
+
return {
|
|
6976
|
+
get: (sessionId) => store.get(sessionId)?.data || [],
|
|
6977
|
+
repaint: (sessionId) => paintSectionSlots(attr, sessionId, innerHtml(sessionId)),
|
|
6978
|
+
async load(sessionId) {
|
|
6979
|
+
if (!sessionId || inFlight.has(sessionId)) return;
|
|
6980
|
+
const cached = store.get(sessionId);
|
|
6981
|
+
if (cached && Date.now() - cached.at < ttlMs) return;
|
|
6982
|
+
inFlight.add(sessionId);
|
|
6983
|
+
try {
|
|
6984
|
+
const res = await fetch(`/api/sessions/${sessionId}/${endpoint}`);
|
|
6985
|
+
if (!res.ok) return;
|
|
6986
|
+
store.set(sessionId, { at: Date.now(), data: pick(await res.json()) });
|
|
6987
|
+
} catch (_) {
|
|
6988
|
+
return;
|
|
6989
|
+
} finally {
|
|
6990
|
+
inFlight.delete(sessionId);
|
|
6991
|
+
}
|
|
6992
|
+
paintSectionSlots(attr, sessionId, innerHtml(sessionId));
|
|
6993
|
+
},
|
|
6994
|
+
};
|
|
6995
|
+
}
|
|
6546
6996
|
|
|
6547
6997
|
function artifactLabel(a) {
|
|
6548
6998
|
if (a.title) return a.title;
|
|
@@ -6550,7 +7000,7 @@ function artifactLabel(a) {
|
|
|
6550
7000
|
}
|
|
6551
7001
|
|
|
6552
7002
|
function artifactsInnerHtml(sessionId) {
|
|
6553
|
-
const list =
|
|
7003
|
+
const list = artifactsSection.get(sessionId);
|
|
6554
7004
|
if (!list.length) return '';
|
|
6555
7005
|
const items = list
|
|
6556
7006
|
.map(
|
|
@@ -6574,27 +7024,196 @@ function renderArtifactsHtml(sessionId) {
|
|
|
6574
7024
|
return `<div class="artifacts-section panel-section" data-artifacts-for="${escapeHtml(sessionId)}">${artifactsInnerHtml(sessionId)}</div>`;
|
|
6575
7025
|
}
|
|
6576
7026
|
|
|
6577
|
-
//
|
|
6578
|
-
//
|
|
6579
|
-
|
|
6580
|
-
|
|
6581
|
-
|
|
7027
|
+
// No TTL: the server holds the scan until the transcript grows, so a repeat costs one
|
|
7028
|
+
// stat, and an artifact published while the panel is open still appears.
|
|
7029
|
+
const artifactsSection = makeSectionLoader({
|
|
7030
|
+
attr: 'data-artifacts-for',
|
|
7031
|
+
endpoint: 'artifacts',
|
|
7032
|
+
pick: (data) => data.artifacts || [],
|
|
7033
|
+
innerHtml: (sessionId) => artifactsInnerHtml(sessionId),
|
|
7034
|
+
});
|
|
7035
|
+
|
|
7036
|
+
// The server decides liveness (a run has no terminal journal entry) and answers with
|
|
7037
|
+
// nothing once the workflow finishes, so the section empties itself and disappears.
|
|
7038
|
+
const workflowLiveSection = makeSectionLoader({
|
|
7039
|
+
attr: 'data-workflow-live-for',
|
|
7040
|
+
endpoint: 'workflow-live',
|
|
7041
|
+
pick: (data) => (data.workflow ? [data.workflow] : []),
|
|
7042
|
+
innerHtml: (sessionId) => workflowLiveInnerHtml(sessionId),
|
|
7043
|
+
// The agent hooks re-render the sidebar on every subagent start and stop, so the TTL is
|
|
7044
|
+
// what keeps that burst down to one ask per run step.
|
|
7045
|
+
ttlMs: 4000,
|
|
7046
|
+
});
|
|
7047
|
+
|
|
7048
|
+
function workflowLiveInnerHtml(sessionId) {
|
|
7049
|
+
const run = workflowLiveSection.get(sessionId)[0];
|
|
7050
|
+
if (!run) return '';
|
|
7051
|
+
const pct = run.startedCount ? Math.round((run.doneCount / run.startedCount) * 100) : 0;
|
|
7052
|
+
const phases = (run.phases || [])
|
|
7053
|
+
.map((p) => {
|
|
7054
|
+
const state = p.started === 0 ? 'pending' : p.done >= p.started ? 'done' : 'running';
|
|
7055
|
+
const count = p.started ? `${p.done}/${p.started}` : '';
|
|
7056
|
+
return `<span class="wf-live-phase ${escapeHtml(state)}">
|
|
7057
|
+
<span class="wf-live-phase-title">${escapeHtml(p.title)}</span>
|
|
7058
|
+
<span class="wf-live-phase-count">${count}</span>
|
|
7059
|
+
</span>`;
|
|
7060
|
+
})
|
|
7061
|
+
.join('');
|
|
7062
|
+
const running = run.running?.length
|
|
7063
|
+
? `<div class="wf-live-running" title="${escapeHtml(run.running.join(', '))}">${escapeHtml(run.running.join(' · '))}</div>`
|
|
7064
|
+
: '';
|
|
7065
|
+
return `<div class="panel-section-header">
|
|
7066
|
+
${WORKFLOW_GEAR_SVG}
|
|
7067
|
+
<span>Workflow</span>
|
|
7068
|
+
<span class="panel-section-count">${run.doneCount}/${run.startedCount} agents</span>
|
|
7069
|
+
</div>
|
|
7070
|
+
<div class="wf-live" onclick="showWorkflowModal('${escAttrJs(sessionId)}')" title="Open workflow run">
|
|
7071
|
+
<div class="wf-live-name"><span class="pulse"></span>${escapeHtml(run.name || run.id)}</div>
|
|
7072
|
+
<div class="progress-bar wf-live-bar"><div class="progress-fill" style="width: ${pct}%"></div></div>
|
|
7073
|
+
${phases ? `<div class="wf-live-phases">${phases}</div>` : ''}
|
|
7074
|
+
${running}
|
|
7075
|
+
</div>`;
|
|
7076
|
+
}
|
|
7077
|
+
|
|
7078
|
+
function renderWorkflowLiveHtml(sessionId) {
|
|
7079
|
+
return `<div class="workflow-live-section" data-workflow-live-for="${escapeHtml(sessionId)}">${workflowLiveInnerHtml(sessionId)}</div>`;
|
|
7080
|
+
}
|
|
7081
|
+
|
|
7082
|
+
// Every agent start and stop already re-renders the sidebar through the agent-activity
|
|
7083
|
+
// feed, which is what keeps the card current. The timer is the fallback for a config dir
|
|
7084
|
+
// with no cck hooks installed: there the last agent's result reaches nobody, and the card
|
|
7085
|
+
// would sit on a finished run until the next render. Slow on purpose — the hooked case
|
|
7086
|
+
// never waits for it. The generation guard drops a chain left mid-fetch by an earlier
|
|
7087
|
+
// render, so renders can't stack up chains.
|
|
7088
|
+
const WORKFLOW_LIVE_POLL_MS = 30000;
|
|
7089
|
+
let workflowLivePoll = null;
|
|
7090
|
+
let workflowLivePollFor = null;
|
|
7091
|
+
|
|
7092
|
+
function pollWorkflowLive(sessionId) {
|
|
7093
|
+
workflowLiveSection.load(sessionId);
|
|
7094
|
+
// A render must not restart the timer: renders arrive far faster than the interval, and
|
|
7095
|
+
// each restart would push the fallback tick back out of reach, leaving a finished run on
|
|
7096
|
+
// screen forever in a config dir with no hooks.
|
|
7097
|
+
if (workflowLivePoll && workflowLivePollFor === sessionId) return;
|
|
7098
|
+
clearTimeout(workflowLivePoll);
|
|
7099
|
+
workflowLivePollFor = sessionId;
|
|
7100
|
+
const tick = async () => {
|
|
7101
|
+
workflowLivePoll = null;
|
|
7102
|
+
if (workflowLivePollFor !== sessionId) return;
|
|
7103
|
+
if (!document.querySelector(`[data-workflow-live-for="${CSS.escape(sessionId)}"]`)) return;
|
|
7104
|
+
await workflowLiveSection.load(sessionId);
|
|
7105
|
+
if (workflowLivePollFor !== sessionId || !workflowLiveSection.get(sessionId).length) return;
|
|
7106
|
+
workflowLivePoll = setTimeout(tick, WORKFLOW_LIVE_POLL_MS);
|
|
7107
|
+
};
|
|
7108
|
+
workflowLivePoll = setTimeout(tick, WORKFLOW_LIVE_POLL_MS);
|
|
7109
|
+
}
|
|
7110
|
+
|
|
7111
|
+
const SCRATCH_FILES_COLLAPSED = 3;
|
|
7112
|
+
// Unlike the artifacts scan the server caches nothing here, so a TTL is what stops the
|
|
7113
|
+
// zen panel — re-rendered on every SSE tick — from turning a readdir plus a stat per
|
|
7114
|
+
// file into a 2 s poll.
|
|
7115
|
+
const scratchFilesSection = makeSectionLoader({
|
|
7116
|
+
attr: 'data-scratch-files-for',
|
|
7117
|
+
endpoint: 'scratchpad-files',
|
|
7118
|
+
pick: (data) => data.files || [],
|
|
7119
|
+
innerHtml: (sessionId) => scratchFilesInnerHtml(sessionId),
|
|
7120
|
+
ttlMs: 10000,
|
|
7121
|
+
});
|
|
7122
|
+
// Outlives the cache entries on purpose: a refetch on the TTL must not collapse a list
|
|
7123
|
+
// the user opened.
|
|
7124
|
+
const scratchFilesExpanded = new Set();
|
|
7125
|
+
// Open folders only, keyed by absolute path: path -> { status: 'loading' | 'ready' |
|
|
7126
|
+
// 'error', children }. Closing a folder drops its entry, so the cache is bounded by
|
|
7127
|
+
// what is on screen and a reopen shows fresh contents for one readdir.
|
|
7128
|
+
const scratchFolders = new Map();
|
|
7129
|
+
|
|
7130
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
7131
|
+
function openScratchFile(filePath) {
|
|
7132
|
+
openPreviewByPath(filePath, undefined, openFileInEditor);
|
|
7133
|
+
}
|
|
7134
|
+
|
|
7135
|
+
function scratchRowHtml(f) {
|
|
7136
|
+
const escPath = escapeHtml(f.path);
|
|
7137
|
+
const isDir = f.kind === 'dir';
|
|
7138
|
+
const folder = isDir ? scratchFolders.get(f.path) : null;
|
|
7139
|
+
const name = escapeHtml(f.name);
|
|
7140
|
+
const link = isDir
|
|
7141
|
+
? `<button type="button" class="scratch-file-link scratch-folder-toggle${folder ? '' : ' collapsed'}" title="${escPath}" ${folder ? 'aria-expanded="true"' : 'aria-expanded="false"'}>${groupChevronSvg(10)}${name}/</button>`
|
|
7142
|
+
: `<button type="button" class="scratch-file-link" onclick="openScratchFile(this.closest('li').dataset.file)" title="${escPath}">${name}</button>`;
|
|
7143
|
+
let body = '';
|
|
7144
|
+
if (folder?.status === 'loading') body = '<div class="scratch-folder-empty">Loading…</div>';
|
|
7145
|
+
else if (folder?.status === 'error') body = '<div class="scratch-folder-empty">Failed to load</div>';
|
|
7146
|
+
else if (folder && !folder.children.length) body = '<div class="scratch-folder-empty">Empty</div>';
|
|
7147
|
+
else if (folder) body = scratchRowsHtml(folder.children);
|
|
7148
|
+
return `<li class="scratch-file-item${isDir ? ' scratch-folder-item' : ''}" data-file="${escPath}">
|
|
7149
|
+
${link}
|
|
7150
|
+
<span class="scratch-file-time">${formatDate(f.modifiedAt)}</span>
|
|
7151
|
+
<span class="row-actions scratch-file-actions">
|
|
7152
|
+
<button type="button" onclick="copyWithFeedback(this.closest('li').dataset.file, this)" title="Copy path" aria-label="Copy path">${ICON_COPY}</button>
|
|
7153
|
+
<button type="button" onclick="${isDir ? 'openFolderInEditor' : 'openFileInEditor'}(this.closest('li').dataset.file)" title="Open in editor" aria-label="Open in editor">${ICON_OPEN_EXTERNAL}</button>
|
|
7154
|
+
</span>
|
|
7155
|
+
${body}
|
|
7156
|
+
</li>`;
|
|
7157
|
+
}
|
|
7158
|
+
|
|
7159
|
+
function scratchRowsHtml(rows) {
|
|
7160
|
+
return `<ul class="scratch-file-list">${rows.map(scratchRowHtml).join('')}</ul>`;
|
|
7161
|
+
}
|
|
7162
|
+
|
|
7163
|
+
function scratchFilesInnerHtml(sessionId) {
|
|
7164
|
+
const list = scratchFilesSection.get(sessionId);
|
|
7165
|
+
if (!list.length) return '';
|
|
7166
|
+
const expanded = scratchFilesExpanded.has(sessionId);
|
|
7167
|
+
const shown = expanded ? list : list.slice(0, SCRATCH_FILES_COLLAPSED);
|
|
7168
|
+
const more =
|
|
7169
|
+
list.length > SCRATCH_FILES_COLLAPSED
|
|
7170
|
+
? `<button type="button" class="expand-toggle-btn scratch-files-more" onclick="toggleScratchFiles('${escAttrJs(sessionId)}')">${expanded ? 'Show less' : `Show all ${list.length}`}</button>`
|
|
7171
|
+
: '';
|
|
7172
|
+
return `${scratchRowsHtml(shown)}${more}`;
|
|
7173
|
+
}
|
|
7174
|
+
|
|
7175
|
+
// Delegated at the document because the whole zen panel is re-rendered on every SSE tick,
|
|
7176
|
+
// which would drop a listener bound on the section.
|
|
7177
|
+
document.addEventListener('click', (e) => {
|
|
7178
|
+
const toggle = e.target.closest('.scratch-folder-toggle');
|
|
7179
|
+
if (!toggle) return;
|
|
7180
|
+
const section = toggle.closest('[data-scratch-files-for]');
|
|
7181
|
+
if (section) toggleScratchFolder(section.dataset.scratchFilesFor, toggle.closest('li').dataset.file);
|
|
7182
|
+
});
|
|
7183
|
+
|
|
7184
|
+
async function toggleScratchFolder(sessionId, dirPath) {
|
|
7185
|
+
if (scratchFolders.has(dirPath)) {
|
|
7186
|
+
scratchFolders.delete(dirPath);
|
|
7187
|
+
scratchFilesSection.repaint(sessionId);
|
|
7188
|
+
return;
|
|
7189
|
+
}
|
|
7190
|
+
scratchFolders.set(dirPath, { status: 'loading', children: [] });
|
|
7191
|
+
scratchFilesSection.repaint(sessionId);
|
|
7192
|
+
let entry = { status: 'error', children: [] };
|
|
6582
7193
|
try {
|
|
6583
|
-
const res = await fetch(`/api/sessions/${sessionId}/
|
|
6584
|
-
if (
|
|
6585
|
-
const data = await res.json();
|
|
6586
|
-
artifactsBySession.set(sessionId, data.artifacts || []);
|
|
7194
|
+
const res = await fetch(`/api/sessions/${sessionId}/scratchpad-files?path=${encodeURIComponent(dirPath)}`);
|
|
7195
|
+
if (res.ok) entry = { status: 'ready', children: (await res.json()).files || [] };
|
|
6587
7196
|
} catch (_) {
|
|
6588
|
-
|
|
6589
|
-
} finally {
|
|
6590
|
-
artifactsInFlight.delete(sessionId);
|
|
7197
|
+
// Reads "Failed to load"; closing and reopening the folder retries.
|
|
6591
7198
|
}
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
7199
|
+
// The user may have closed the folder while the request was in flight.
|
|
7200
|
+
if (scratchFolders.has(dirPath)) {
|
|
7201
|
+
scratchFolders.set(dirPath, entry);
|
|
7202
|
+
scratchFilesSection.repaint(sessionId);
|
|
6595
7203
|
}
|
|
6596
7204
|
}
|
|
6597
7205
|
|
|
7206
|
+
function renderScratchFilesHtml(sessionId) {
|
|
7207
|
+
return `<div class="scratch-files-section" data-scratch-files-for="${escapeHtml(sessionId)}">${scratchFilesInnerHtml(sessionId)}</div>`;
|
|
7208
|
+
}
|
|
7209
|
+
|
|
7210
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
7211
|
+
function toggleScratchFiles(sessionId) {
|
|
7212
|
+
if (scratchFilesExpanded.has(sessionId)) scratchFilesExpanded.delete(sessionId);
|
|
7213
|
+
else scratchFilesExpanded.add(sessionId);
|
|
7214
|
+
scratchFilesSection.repaint(sessionId);
|
|
7215
|
+
}
|
|
7216
|
+
|
|
6598
7217
|
// One delegated listener per section: the list is re-rendered on every change, so
|
|
6599
7218
|
// per-row handlers would only be rebound each time anyway.
|
|
6600
7219
|
function bindLinkedDocsHandlers(container, sessionId) {
|
|
@@ -6664,7 +7283,7 @@ async function linkFileByPath(sessionId, raw, slot) {
|
|
|
6664
7283
|
if (base) qs.set('base', base);
|
|
6665
7284
|
const r = await fetch(`/api/file/resolve?${qs}`);
|
|
6666
7285
|
const data = await r.json().catch(() => ({}));
|
|
6667
|
-
if (!r.ok) {
|
|
7286
|
+
if (!r.ok || !data.exists) {
|
|
6668
7287
|
fail(data.error || 'File not found');
|
|
6669
7288
|
return;
|
|
6670
7289
|
}
|
|
@@ -6698,6 +7317,24 @@ function setupEventSource() {
|
|
|
6698
7317
|
failCount = 0;
|
|
6699
7318
|
}
|
|
6700
7319
|
|
|
7320
|
+
let taskRefreshTimer = null;
|
|
7321
|
+
let metadataRefreshTimer = null;
|
|
7322
|
+
let metadataDeadline = 0;
|
|
7323
|
+
let agentRefreshTimer = null;
|
|
7324
|
+
const pendingTaskSessionIds = new Set();
|
|
7325
|
+
const pendingAgentSessionIds = new Set();
|
|
7326
|
+
|
|
7327
|
+
function refreshAllView() {
|
|
7328
|
+
currentTasks = filterProject ? allTasksCache.filter((t) => matchesProjectFilter(t.project)) : allTasksCache;
|
|
7329
|
+
renderAllTasks();
|
|
7330
|
+
renderActivityChip();
|
|
7331
|
+
}
|
|
7332
|
+
|
|
7333
|
+
async function refreshSessionDetail(sessionId) {
|
|
7334
|
+
await fetchAgents(sessionId);
|
|
7335
|
+
if (!agentLogMode) fetchMessages(sessionId);
|
|
7336
|
+
}
|
|
7337
|
+
|
|
6701
7338
|
function connect() {
|
|
6702
7339
|
eventSource = new EventSource('/api/events');
|
|
6703
7340
|
|
|
@@ -6706,6 +7343,10 @@ function setupEventSource() {
|
|
|
6706
7343
|
console.warn('[SSE] Reconnected after drop — forcing full refresh');
|
|
6707
7344
|
fetchSessions().catch(() => {});
|
|
6708
7345
|
if (currentSessionId) fetchTasks(currentSessionId);
|
|
7346
|
+
if (terminalAvailable())
|
|
7347
|
+
loadTerminals()
|
|
7348
|
+
.then(renderSessions)
|
|
7349
|
+
.catch(() => {});
|
|
6709
7350
|
}
|
|
6710
7351
|
wasConnected = true;
|
|
6711
7352
|
retryDelay = 1000;
|
|
@@ -6721,31 +7362,29 @@ function setupEventSource() {
|
|
|
6721
7362
|
retryDelay = Math.min(retryDelay * 2, 30000);
|
|
6722
7363
|
};
|
|
6723
7364
|
|
|
6724
|
-
let taskRefreshTimer = null;
|
|
6725
|
-
let metadataRefreshTimer = null;
|
|
6726
|
-
let agentRefreshTimer = null;
|
|
6727
|
-
const pendingTaskSessionIds = new Set();
|
|
6728
|
-
const pendingAgentSessionIds = new Set();
|
|
6729
|
-
|
|
6730
7365
|
function debouncedRefresh(sessionId, isMetadata) {
|
|
6731
7366
|
if (isMetadata) {
|
|
7367
|
+
// Busy sessions emit events faster than the 2 s quiet period, so a pure debounce would
|
|
7368
|
+
// never fire; the deadline caps how long a burst can hold the refresh back.
|
|
7369
|
+
metadataDeadline ||= Date.now() + METADATA_MAX_WAIT_MS;
|
|
6732
7370
|
clearTimeout(metadataRefreshTimer);
|
|
6733
|
-
metadataRefreshTimer = setTimeout(
|
|
6734
|
-
|
|
6735
|
-
|
|
6736
|
-
|
|
6737
|
-
|
|
6738
|
-
|
|
6739
|
-
|
|
7371
|
+
metadataRefreshTimer = setTimeout(
|
|
7372
|
+
() => {
|
|
7373
|
+
metadataDeadline = 0;
|
|
7374
|
+
if (skipOffScreen()) return;
|
|
7375
|
+
fetchSessions(false).catch((err) => console.error('[SSE] fetchSessions failed:', err));
|
|
7376
|
+
if (currentSessionId) refreshSessionDetail(currentSessionId);
|
|
7377
|
+
},
|
|
7378
|
+
Math.min(2000, metadataDeadline - Date.now()),
|
|
7379
|
+
);
|
|
6740
7380
|
} else {
|
|
6741
7381
|
pendingTaskSessionIds.add(sessionId);
|
|
6742
7382
|
clearTimeout(taskRefreshTimer);
|
|
6743
7383
|
taskRefreshTimer = setTimeout(async () => {
|
|
7384
|
+
if (skipOffScreen()) return;
|
|
6744
7385
|
await fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
|
|
6745
7386
|
if (viewMode === 'all') {
|
|
6746
|
-
|
|
6747
|
-
renderAllTasks();
|
|
6748
|
-
renderActivityChip();
|
|
7387
|
+
refreshAllView();
|
|
6749
7388
|
} else if (viewMode === 'project' && currentProjectPath) {
|
|
6750
7389
|
const hasUpdate = currentProjectSessionIds.some((id) => pendingTaskSessionIds.has(id));
|
|
6751
7390
|
if (hasUpdate) fetchProjectView(currentProjectPath);
|
|
@@ -6765,23 +7404,27 @@ function setupEventSource() {
|
|
|
6765
7404
|
}
|
|
6766
7405
|
|
|
6767
7406
|
if (data.type === 'plan-update') {
|
|
6768
|
-
refreshOpenPlan();
|
|
7407
|
+
if (!skipOffScreen()) refreshOpenPlan();
|
|
6769
7408
|
}
|
|
6770
7409
|
|
|
7410
|
+
if (data.type === 'terminals-update' && Array.isArray(data.ids)) setRunningTerminals(data.ids);
|
|
7411
|
+
|
|
7412
|
+
if (data.type === 'dispatch-update') onDispatchUpdate();
|
|
7413
|
+
|
|
6771
7414
|
if (data.type === 'agent-update') {
|
|
6772
7415
|
pendingAgentSessionIds.add(data.sessionId);
|
|
6773
7416
|
clearTimeout(agentRefreshTimer);
|
|
6774
7417
|
agentRefreshTimer = setTimeout(() => {
|
|
7418
|
+
if (skipOffScreen()) return;
|
|
6775
7419
|
fetchSessions(false).catch((err) => console.error('[SSE] fetchSessions failed:', err));
|
|
6776
7420
|
if (viewMode === 'project' && currentProjectSessionIds.some((id) => pendingAgentSessionIds.has(id))) {
|
|
6777
7421
|
refreshProjectAgents();
|
|
6778
7422
|
} else if (currentSessionId && pendingAgentSessionIds.has(currentSessionId)) {
|
|
6779
|
-
fetchAgents(currentSessionId);
|
|
6780
7423
|
// An agent starting/stopping adds its Agent tool_use + completion rows to
|
|
6781
7424
|
// the transcript. Refresh the message log on the same signal so they appear
|
|
6782
7425
|
// at start, not only when an unrelated metadata/context refresh coincides
|
|
6783
7426
|
// with completion.
|
|
6784
|
-
|
|
7427
|
+
refreshSessionDetail(currentSessionId);
|
|
6785
7428
|
}
|
|
6786
7429
|
pendingAgentSessionIds.clear();
|
|
6787
7430
|
}, 500);
|
|
@@ -6789,7 +7432,7 @@ function setupEventSource() {
|
|
|
6789
7432
|
|
|
6790
7433
|
if (data.type === 'context-update') {
|
|
6791
7434
|
debouncedRefresh(data.sessionId, true);
|
|
6792
|
-
refreshRateLimits();
|
|
7435
|
+
if (!skipOffScreen()) refreshRateLimits();
|
|
6793
7436
|
}
|
|
6794
7437
|
|
|
6795
7438
|
if (data.type === 'preview:open') {
|
|
@@ -6819,20 +7462,42 @@ function setupEventSource() {
|
|
|
6819
7462
|
};
|
|
6820
7463
|
}
|
|
6821
7464
|
|
|
6822
|
-
// When
|
|
6823
|
-
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
7465
|
+
// When cck comes back on screen (tab visible or hub:active), catch up immediately
|
|
7466
|
+
async function catchUp() {
|
|
7467
|
+
if (!isOnScreen() || !missedWhileHidden) return;
|
|
7468
|
+
missedWhileHidden = false;
|
|
7469
|
+
// The full refresh below covers whatever the pending debounces would have fetched.
|
|
7470
|
+
for (const t of [taskRefreshTimer, metadataRefreshTimer, agentRefreshTimer]) clearTimeout(t);
|
|
7471
|
+
metadataDeadline = 0;
|
|
7472
|
+
pendingTaskSessionIds.clear();
|
|
7473
|
+
pendingAgentSessionIds.clear();
|
|
7474
|
+
const sessionsLoaded = fetchSessions().catch(() => {});
|
|
7475
|
+
if (viewMode === 'all') {
|
|
7476
|
+
await sessionsLoaded;
|
|
7477
|
+
refreshAllView();
|
|
7478
|
+
} else if (viewMode === 'project' && currentProjectPath) {
|
|
7479
|
+
await sessionsLoaded;
|
|
7480
|
+
fetchProjectView(currentProjectPath);
|
|
7481
|
+
} else if (currentSessionId) {
|
|
7482
|
+
fetchTasks(currentSessionId).catch(() => {});
|
|
7483
|
+
await refreshSessionDetail(currentSessionId);
|
|
7484
|
+
}
|
|
7485
|
+
refreshOpenPlan();
|
|
7486
|
+
refreshRateLimits();
|
|
7487
|
+
renderAgentFooter();
|
|
7488
|
+
}
|
|
7489
|
+
document.addEventListener('visibilitychange', catchUp);
|
|
7490
|
+
window.addEventListener('message', (e) => {
|
|
7491
|
+
if (e.source !== window.parent || e.origin !== hubOrigin() || e.data?.type !== 'hub:active') return;
|
|
7492
|
+
hubActive = !!e.data.active;
|
|
7493
|
+
catchUp();
|
|
7494
|
+
if (hubActive && wantsTerminal()) onTerminalShown();
|
|
6830
7495
|
});
|
|
6831
7496
|
|
|
6832
|
-
// Fallback poll every 30s in case SSE silently drops; skip when
|
|
7497
|
+
// Fallback poll every 30s in case SSE silently drops; skip when not on screen
|
|
6833
7498
|
setInterval(() => {
|
|
6834
|
-
if (
|
|
6835
|
-
|
|
7499
|
+
if (!isOnScreen()) {
|
|
7500
|
+
if (eventSource?.readyState !== EventSource.OPEN) missedWhileHidden = true;
|
|
6836
7501
|
return;
|
|
6837
7502
|
}
|
|
6838
7503
|
fetchSessions().catch(() => {});
|
|
@@ -6942,7 +7607,7 @@ function formatCost(usd) {
|
|
|
6942
7607
|
return `$${usd.toFixed(2)}`;
|
|
6943
7608
|
}
|
|
6944
7609
|
|
|
6945
|
-
function renderContextDetail(raw) {
|
|
7610
|
+
function renderContextDetail(raw, { tokens = true } = {}) {
|
|
6946
7611
|
const ctx = getCtx(raw);
|
|
6947
7612
|
if (!ctx) return '';
|
|
6948
7613
|
const totalK = ctx.size / 1000;
|
|
@@ -6951,6 +7616,16 @@ function renderContextDetail(raw) {
|
|
|
6951
7616
|
const cw = raw.context_window || {};
|
|
6952
7617
|
const usage = cw.current_usage || {};
|
|
6953
7618
|
const cost = raw.cost || {};
|
|
7619
|
+
const tokenRows = tokens
|
|
7620
|
+
? `<div class="stat-item"><span class="stat-label">Cache read</span><span class="stat-value">${formatTokens((usage.cache_read_input_tokens || 0) / 1000)}</span></div>
|
|
7621
|
+
<div class="stat-item"><span class="stat-label">Cache write</span><span class="stat-value">${formatTokens((usage.cache_creation_input_tokens || 0) / 1000)}</span></div>
|
|
7622
|
+
<div class="stat-item"><span class="stat-label">Current input</span><span class="stat-value">${formatTokens((usage.input_tokens || 0) / 1000)}</span></div>
|
|
7623
|
+
<div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens((usage.output_tokens || 0) / 1000)}</span></div>
|
|
7624
|
+
<div class="stat-divider"></div>
|
|
7625
|
+
<div class="stat-item"><span class="stat-label">Total input</span><span class="stat-value">${formatTokens(ctx.inputTokens / 1000)}</span></div>
|
|
7626
|
+
<div class="stat-item"><span class="stat-label">Total output</span><span class="stat-value">${formatTokens(ctx.outputTokens / 1000)}</span></div>
|
|
7627
|
+
<div class="stat-divider"></div>`
|
|
7628
|
+
: '';
|
|
6954
7629
|
|
|
6955
7630
|
return `
|
|
6956
7631
|
<div class="detail-context">
|
|
@@ -6966,14 +7641,7 @@ function renderContextDetail(raw) {
|
|
|
6966
7641
|
<span>${formatTokens((ctx.pct / 100) * totalK)} / ${formatTokens(totalK)}</span>
|
|
6967
7642
|
</div>
|
|
6968
7643
|
<div class="detail-context-stats">
|
|
6969
|
-
|
|
6970
|
-
<div class="stat-item"><span class="stat-label">Cache write</span><span class="stat-value">${formatTokens((usage.cache_creation_input_tokens || 0) / 1000)}</span></div>
|
|
6971
|
-
<div class="stat-item"><span class="stat-label">Current input</span><span class="stat-value">${formatTokens((usage.input_tokens || 0) / 1000)}</span></div>
|
|
6972
|
-
<div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens((usage.output_tokens || 0) / 1000)}</span></div>
|
|
6973
|
-
<div class="stat-divider"></div>
|
|
6974
|
-
<div class="stat-item"><span class="stat-label">Total input</span><span class="stat-value">${formatTokens(ctx.inputTokens / 1000)}</span></div>
|
|
6975
|
-
<div class="stat-item"><span class="stat-label">Total output</span><span class="stat-value">${formatTokens(ctx.outputTokens / 1000)}</span></div>
|
|
6976
|
-
<div class="stat-divider"></div>
|
|
7644
|
+
${tokenRows}
|
|
6977
7645
|
<div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${getCostColor(cost.total_cost_usd)}">${formatCost(cost.total_cost_usd)}</span></div>
|
|
6978
7646
|
<div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>
|
|
6979
7647
|
<div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>
|
|
@@ -6999,11 +7667,18 @@ function maybeFollowLatest() {
|
|
|
6999
7667
|
// on the card and the badge until they get to it. Each ask id is offered once,
|
|
7000
7668
|
// so closing the modal does not bring it back on the next poll.
|
|
7001
7669
|
let lastAutoOpenedWaitingId = null;
|
|
7670
|
+
// Set only on that self-opening path: answering an ask the user never asked to see
|
|
7671
|
+
// should put the screen back the way it was, where answering one they navigated to
|
|
7672
|
+
// keeps them following. Cleared with the modal, so a manual reopen is not "auto".
|
|
7673
|
+
let waitingDetailAutoOpened = false;
|
|
7002
7674
|
function maybeAutoOpenWaiting() {
|
|
7003
7675
|
if (!isWaitingAnswerable() || !isWaitingFresh()) return;
|
|
7004
7676
|
if (currentWaiting.id === lastAutoOpenedWaitingId || isAnyModalOpen()) return;
|
|
7677
|
+
// The terminal on screen shows the same ask; hiding it lets the next poll open the modal.
|
|
7678
|
+
if (termState.shown) return;
|
|
7005
7679
|
lastAutoOpenedWaitingId = currentWaiting.id;
|
|
7006
7680
|
msgDetailFollowLatest = true;
|
|
7681
|
+
waitingDetailAutoOpened = true;
|
|
7007
7682
|
maybeFollowLatest();
|
|
7008
7683
|
}
|
|
7009
7684
|
|
|
@@ -7398,6 +8073,10 @@ function stripTeammateWrapper(text) {
|
|
|
7398
8073
|
// escaping is explicit here.
|
|
7399
8074
|
const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '`': '`' };
|
|
7400
8075
|
|
|
8076
|
+
function pickRandom(items) {
|
|
8077
|
+
return items[Math.floor(Math.random() * items.length)];
|
|
8078
|
+
}
|
|
8079
|
+
|
|
7401
8080
|
function escapeHtml(text) {
|
|
7402
8081
|
if (text == null) return '';
|
|
7403
8082
|
return String(text).replace(/[&<>"'`]/g, (c) => HTML_ESCAPES[c]);
|
|
@@ -7758,6 +8437,13 @@ document.addEventListener('click', (e) => {
|
|
|
7758
8437
|
return;
|
|
7759
8438
|
}
|
|
7760
8439
|
|
|
8440
|
+
const newBtn = e.target.closest('.project-new-btn');
|
|
8441
|
+
if (newBtn) {
|
|
8442
|
+
e.stopPropagation();
|
|
8443
|
+
openNewSession(newBtn.dataset.projectPath);
|
|
8444
|
+
return;
|
|
8445
|
+
}
|
|
8446
|
+
|
|
7761
8447
|
const projectBtn = e.target.closest('.project-view-btn');
|
|
7762
8448
|
if (projectBtn) {
|
|
7763
8449
|
e.stopPropagation();
|
|
@@ -7783,20 +8469,34 @@ document.addEventListener('click', (e) => {
|
|
|
7783
8469
|
if (sgHeader) {
|
|
7784
8470
|
if (e.target.closest('.sg-name-input')) return;
|
|
7785
8471
|
e.stopPropagation();
|
|
7786
|
-
|
|
7787
|
-
|
|
7788
|
-
|
|
7789
|
-
if (e.target.closest('.sg-rename')) {
|
|
7790
|
-
sgBeginRename(groupId);
|
|
8472
|
+
if (e.target.closest('.sg-keep')) {
|
|
8473
|
+
sgKeepTransient(sgHeader.dataset.transientGroup);
|
|
8474
|
+
renderSessions();
|
|
7791
8475
|
return;
|
|
7792
8476
|
}
|
|
7793
|
-
if (e.target.closest('.sg-delete')) {
|
|
7794
|
-
|
|
7795
|
-
|
|
8477
|
+
if (sgHeader.dataset.transientGroup && e.target.closest('.sg-delete')) {
|
|
8478
|
+
const name = sgHeader.dataset.transientGroup;
|
|
8479
|
+
if (confirm(`Delete group “${name}”? Its sessions go back to Projects.`)) {
|
|
8480
|
+
sgDeleteTransient(name);
|
|
7796
8481
|
renderSessions();
|
|
7797
8482
|
}
|
|
7798
8483
|
return;
|
|
7799
8484
|
}
|
|
8485
|
+
const groupId = sgHeader.dataset.groupId;
|
|
8486
|
+
const group = sgGroupById(groupId);
|
|
8487
|
+
if (group) {
|
|
8488
|
+
if (e.target.closest('.sg-rename')) {
|
|
8489
|
+
sgBeginRename(groupId);
|
|
8490
|
+
return;
|
|
8491
|
+
}
|
|
8492
|
+
if (e.target.closest('.sg-delete')) {
|
|
8493
|
+
if (confirm(`Delete group “${group.name}”? Its sessions and projects go back to Projects.`)) {
|
|
8494
|
+
sgDeleteGroup(groupId);
|
|
8495
|
+
renderSessions();
|
|
8496
|
+
}
|
|
8497
|
+
return;
|
|
8498
|
+
}
|
|
8499
|
+
} else if (!sgHeader.dataset.transientGroup) return;
|
|
7800
8500
|
}
|
|
7801
8501
|
|
|
7802
8502
|
const header = e.target.closest(COLLAPSIBLE_HEADER_SELECTOR);
|
|
@@ -7816,14 +8516,8 @@ function filterByProject(project) {
|
|
|
7816
8516
|
|
|
7817
8517
|
let projectsCache = null;
|
|
7818
8518
|
|
|
7819
|
-
async function
|
|
7820
|
-
|
|
7821
|
-
|
|
7822
|
-
if (!projectsCacheDirty && projectsCache) {
|
|
7823
|
-
renderProjectDropdown(dropdown, projectsCache);
|
|
7824
|
-
return;
|
|
7825
|
-
}
|
|
7826
|
-
|
|
8519
|
+
async function loadProjects() {
|
|
8520
|
+
if (!projectsCacheDirty && projectsCache) return projectsCache;
|
|
7827
8521
|
let projects;
|
|
7828
8522
|
try {
|
|
7829
8523
|
const res = await fetch('/api/projects');
|
|
@@ -7833,10 +8527,20 @@ async function updateProjectDropdown() {
|
|
|
7833
8527
|
.sort()
|
|
7834
8528
|
.map((p) => ({ path: p, modifiedAt: null }));
|
|
7835
8529
|
}
|
|
7836
|
-
|
|
7837
8530
|
projectsCache = projects;
|
|
7838
8531
|
projectsCacheDirty = false;
|
|
8532
|
+
return projects;
|
|
8533
|
+
}
|
|
8534
|
+
|
|
8535
|
+
async function updateProjectDropdown() {
|
|
8536
|
+
const dropdown = document.getElementById('project-filter');
|
|
7839
8537
|
|
|
8538
|
+
if (!projectsCacheDirty && projectsCache) {
|
|
8539
|
+
renderProjectDropdown(dropdown, projectsCache);
|
|
8540
|
+
return;
|
|
8541
|
+
}
|
|
8542
|
+
|
|
8543
|
+
const projects = await loadProjects();
|
|
7840
8544
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
7841
8545
|
const prevRecent = recentProjects;
|
|
7842
8546
|
recentProjects = new Set(
|
|
@@ -8124,7 +8828,11 @@ async function showSessionInfoModal(sessionId) {
|
|
|
8124
8828
|
_planSessionId = sessionId;
|
|
8125
8829
|
const cachedTasks = currentSessionId === sessionId ? currentTasks : [];
|
|
8126
8830
|
showInfoModal(session, null, cachedTasks, null, null);
|
|
8127
|
-
|
|
8831
|
+
artifactsSection.load(sessionId);
|
|
8832
|
+
// The modal reaches any card's session, not just the open one, so this is the second
|
|
8833
|
+
// place a pad can first be seen. Linking belongs on the two open events rather than
|
|
8834
|
+
// in renderLinkedDocsHtml, which only builds a string and runs on every repaint.
|
|
8835
|
+
loadSessionPads(sessionId);
|
|
8128
8836
|
|
|
8129
8837
|
const rerender = (teamConfig, tasks, planContent, parentInfo) => {
|
|
8130
8838
|
if (_planSessionId !== sessionId) return; // user opened a different modal
|
|
@@ -8196,7 +8904,8 @@ function renderScratchpadRow(session) {
|
|
|
8196
8904
|
<button onclick="copyWithFeedback('${escAttrJs(dir)}', this)" title="Copy">${ICON_COPY}</button>
|
|
8197
8905
|
<button data-folder="${escapeHtml(dir)}" onclick="openFolderInEditor(this.dataset.folder)" title="Open in editor">${ICON_OPEN_EXTERNAL}</button>
|
|
8198
8906
|
</span>
|
|
8199
|
-
</div
|
|
8907
|
+
</div>
|
|
8908
|
+
${renderScratchFilesHtml(session.id)}`;
|
|
8200
8909
|
}
|
|
8201
8910
|
|
|
8202
8911
|
function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
@@ -8446,11 +9155,36 @@ let spPeekedId = null;
|
|
|
8446
9155
|
// Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
|
|
8447
9156
|
const spPeekCache = new Map();
|
|
8448
9157
|
|
|
9158
|
+
// A session outside the sidebar filter has no name to fuzzy-match against, so a full id
|
|
9159
|
+
// is read as a request for that exact session and resolved against the server instead.
|
|
9160
|
+
// Values: 'pending' while the request is out, then the session or null.
|
|
9161
|
+
const spGlobal = new Map();
|
|
9162
|
+
|
|
9163
|
+
async function spResolveGlobal(id) {
|
|
9164
|
+
spGlobal.set(id, 'pending');
|
|
9165
|
+
spGlobal.set(id, await fetchSessionById(id));
|
|
9166
|
+
if (document.getElementById('session-picker-input').value.trim().toLowerCase() === id) renderSessionPicker();
|
|
9167
|
+
}
|
|
9168
|
+
|
|
9169
|
+
// The row to show for a query nothing in the list matched, as `{ hit, empty }` — one of
|
|
9170
|
+
// the two is always null. Starts the lookup when the query is an id not asked about yet.
|
|
9171
|
+
function spResolveMiss(query) {
|
|
9172
|
+
if (!SESSION_UUID_RE.test(query)) {
|
|
9173
|
+
return { hit: null, empty: spSource.length ? 'No session matches' : 'No sessions in the current sidebar filter' };
|
|
9174
|
+
}
|
|
9175
|
+
const id = query.toLowerCase();
|
|
9176
|
+
if (!spGlobal.has(id)) spResolveGlobal(id);
|
|
9177
|
+
const found = spGlobal.get(id);
|
|
9178
|
+
if (found === 'pending') return { hit: null, empty: 'Looking up session…' };
|
|
9179
|
+
return found ? { hit: found, empty: null } : { hit: null, empty: 'No session with that id' };
|
|
9180
|
+
}
|
|
9181
|
+
|
|
8449
9182
|
function openSessionPicker() {
|
|
8450
9183
|
const input = document.getElementById('session-picker-input');
|
|
8451
9184
|
input.value = '';
|
|
8452
9185
|
spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
|
|
8453
9186
|
spPeekCache.clear();
|
|
9187
|
+
spGlobal.clear();
|
|
8454
9188
|
document.getElementById('session-picker-modal').classList.add('visible');
|
|
8455
9189
|
renderSessionPicker();
|
|
8456
9190
|
input.focus();
|
|
@@ -8541,11 +9275,14 @@ function renderSessionPicker() {
|
|
|
8541
9275
|
const list = document.getElementById('session-picker-list');
|
|
8542
9276
|
const query = document.getElementById('session-picker-input').value.trim();
|
|
8543
9277
|
spRows = spSource.filter((s) => spMatches(s, query));
|
|
8544
|
-
|
|
8545
9278
|
if (!spRows.length) {
|
|
8546
|
-
|
|
8547
|
-
|
|
8548
|
-
|
|
9279
|
+
const { hit, empty } = spResolveMiss(query);
|
|
9280
|
+
if (hit) spRows = [hit];
|
|
9281
|
+
else {
|
|
9282
|
+
spIdx = -1;
|
|
9283
|
+
list.innerHTML = `<div class="sp-empty">${empty}</div>`;
|
|
9284
|
+
return;
|
|
9285
|
+
}
|
|
8549
9286
|
}
|
|
8550
9287
|
|
|
8551
9288
|
list.innerHTML = spRows
|
|
@@ -8599,6 +9336,19 @@ function initSessionPicker() {
|
|
|
8599
9336
|
const row = e.target.closest('.sp-row');
|
|
8600
9337
|
if (row) spOpen(Number(row.dataset.idx));
|
|
8601
9338
|
});
|
|
9339
|
+
// Vimium eats Escape inside a text input and only blurs it, so the page never sees the key.
|
|
9340
|
+
// A blur that no click in the picker caused, while the window keeps focus, is that Escape.
|
|
9341
|
+
const modal = document.getElementById('session-picker-modal');
|
|
9342
|
+
let pointerDown = false;
|
|
9343
|
+
modal.addEventListener('mousedown', () => {
|
|
9344
|
+
pointerDown = true;
|
|
9345
|
+
});
|
|
9346
|
+
document.addEventListener('mouseup', () => {
|
|
9347
|
+
pointerDown = false;
|
|
9348
|
+
});
|
|
9349
|
+
input.addEventListener('blur', () => {
|
|
9350
|
+
if (!pointerDown && modal.classList.contains('visible') && document.hasFocus()) closeSessionPicker();
|
|
9351
|
+
});
|
|
8602
9352
|
}
|
|
8603
9353
|
//#endregion
|
|
8604
9354
|
|
|
@@ -9183,6 +9933,1274 @@ function filterByOwner(value) {
|
|
|
9183
9933
|
|
|
9184
9934
|
//#endregion
|
|
9185
9935
|
|
|
9936
|
+
//#region TERMINAL
|
|
9937
|
+
// The kanban and the terminal share one slot: terminal mode hides #main-content and shows
|
|
9938
|
+
// the pane. The PTY lives on the server (lib/terminal.js) and outlives the pane, so
|
|
9939
|
+
// leaving the session view or switching sessions drops only the socket; coming back
|
|
9940
|
+
// reattaches and the server replays the screen.
|
|
9941
|
+
const TERMINAL_TOKEN_KEY = 'terminal-token';
|
|
9942
|
+
const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
9943
|
+
// Ctrl+Alt letters cck keeps instead of forwarding to the hub: New session, Resume session, and
|
|
9944
|
+
// Swap to the previous session.
|
|
9945
|
+
const CCK_CTRL_ALT_KEYS = new Set(['KeyN', 'KeyR', 'KeyS']);
|
|
9946
|
+
const TERMINAL_MODES_KEY = 'terminal-sessions';
|
|
9947
|
+
const ACK_BATCH_BYTES = 32 * 1024;
|
|
9948
|
+
const TERMINAL_RETRY_MS = [500, 1000, 2000, 4000, 8000];
|
|
9949
|
+
const TERMINAL_STABLE_MS = 5000;
|
|
9950
|
+
const STALE_TOKEN_MSG = 'The terminal token is out of date. Reload the hub window.';
|
|
9951
|
+
// Read at load, before the first updateUrl() rewrites the URL without the fragment.
|
|
9952
|
+
let terminalToken = readTerminalToken();
|
|
9953
|
+
const termState = {
|
|
9954
|
+
loaded: null,
|
|
9955
|
+
term: null,
|
|
9956
|
+
fit: null,
|
|
9957
|
+
webgl: null,
|
|
9958
|
+
ws: null,
|
|
9959
|
+
sessionId: null,
|
|
9960
|
+
syncedView: null,
|
|
9961
|
+
panelHidden: false,
|
|
9962
|
+
shown: false,
|
|
9963
|
+
attached: false,
|
|
9964
|
+
closeGuard: false,
|
|
9965
|
+
leaving: false,
|
|
9966
|
+
focusNext: false,
|
|
9967
|
+
openedByUser: null,
|
|
9968
|
+
ackPending: 0,
|
|
9969
|
+
ackTimer: null,
|
|
9970
|
+
retryTimer: null,
|
|
9971
|
+
};
|
|
9972
|
+
|
|
9973
|
+
// The hub passes the token in the fragment, which is never sent to a server or logged.
|
|
9974
|
+
function readTerminalToken() {
|
|
9975
|
+
const m = /[#&]t=([^&]*)/.exec(location.hash);
|
|
9976
|
+
if (m && TERMINAL_TOKEN_RE.test(m[1])) {
|
|
9977
|
+
storeTerminalToken(m[1]);
|
|
9978
|
+
history.replaceState(null, '', location.pathname + location.search);
|
|
9979
|
+
return m[1];
|
|
9980
|
+
}
|
|
9981
|
+
try {
|
|
9982
|
+
return sessionStorage.getItem(TERMINAL_TOKEN_KEY);
|
|
9983
|
+
} catch (_) {
|
|
9984
|
+
return null;
|
|
9985
|
+
}
|
|
9986
|
+
}
|
|
9987
|
+
|
|
9988
|
+
function storeTerminalToken(token) {
|
|
9989
|
+
try {
|
|
9990
|
+
sessionStorage.setItem(TERMINAL_TOKEN_KEY, token);
|
|
9991
|
+
} catch (_) {}
|
|
9992
|
+
}
|
|
9993
|
+
|
|
9994
|
+
let tokenRefresh = null;
|
|
9995
|
+
|
|
9996
|
+
// The hub mints a new token each run, and this page can outlive a hub restart. Resolves true only
|
|
9997
|
+
// for a token that differs from the one that just failed, so a caller retries at most once.
|
|
9998
|
+
function refreshTerminalToken() {
|
|
9999
|
+
if (!window.__HUB__?.enabled) return Promise.resolve(false);
|
|
10000
|
+
tokenRefresh ??= requestTerminalToken().finally(() => {
|
|
10001
|
+
tokenRefresh = null;
|
|
10002
|
+
});
|
|
10003
|
+
return tokenRefresh;
|
|
10004
|
+
}
|
|
10005
|
+
|
|
10006
|
+
function requestTerminalToken() {
|
|
10007
|
+
return new Promise((resolve) => {
|
|
10008
|
+
const done = (ok) => {
|
|
10009
|
+
window.removeEventListener('message', onMessage);
|
|
10010
|
+
clearTimeout(timer);
|
|
10011
|
+
resolve(ok);
|
|
10012
|
+
};
|
|
10013
|
+
const onMessage = (e) => {
|
|
10014
|
+
if (e.source !== window.parent || e.origin !== hubOrigin() || e.data?.type !== 'hub:terminalToken') return;
|
|
10015
|
+
const token = e.data.token;
|
|
10016
|
+
if (typeof token !== 'string' || !TERMINAL_TOKEN_RE.test(token) || token === terminalToken) return done(false);
|
|
10017
|
+
terminalToken = token;
|
|
10018
|
+
storeTerminalToken(token);
|
|
10019
|
+
done(true);
|
|
10020
|
+
};
|
|
10021
|
+
const timer = setTimeout(() => done(false), 3000);
|
|
10022
|
+
window.addEventListener('message', onMessage);
|
|
10023
|
+
hubPost({ type: 'hub:terminalToken' });
|
|
10024
|
+
});
|
|
10025
|
+
}
|
|
10026
|
+
|
|
10027
|
+
function terminalAvailable() {
|
|
10028
|
+
return !!appConfig.terminal?.available && !!terminalToken;
|
|
10029
|
+
}
|
|
10030
|
+
|
|
10031
|
+
function terminalModes() {
|
|
10032
|
+
return new Set(readStoredList(TERMINAL_MODES_KEY));
|
|
10033
|
+
}
|
|
10034
|
+
|
|
10035
|
+
function setTerminalMode(sessionId, on) {
|
|
10036
|
+
const modes = terminalModes();
|
|
10037
|
+
if (on) modes.add(sessionId);
|
|
10038
|
+
else modes.delete(sessionId);
|
|
10039
|
+
try {
|
|
10040
|
+
store.setItem(TERMINAL_MODES_KEY, JSON.stringify([...modes].slice(-50)));
|
|
10041
|
+
} catch (_) {}
|
|
10042
|
+
}
|
|
10043
|
+
|
|
10044
|
+
function wantsTerminal() {
|
|
10045
|
+
return terminalAvailable() && viewMode === 'session' && !!currentSessionId && terminalModes().has(currentSessionId);
|
|
10046
|
+
}
|
|
10047
|
+
|
|
10048
|
+
function toggleTerminal() {
|
|
10049
|
+
if (!terminalAvailable() || viewMode !== 'session' || !currentSessionId) return;
|
|
10050
|
+
setTerminalMode(currentSessionId, !terminalModes().has(currentSessionId));
|
|
10051
|
+
termState.focusNext = true;
|
|
10052
|
+
syncTerminal();
|
|
10053
|
+
}
|
|
10054
|
+
|
|
10055
|
+
function terminalPaneFocused() {
|
|
10056
|
+
return document.getElementById('terminal-pane').contains(document.activeElement);
|
|
10057
|
+
}
|
|
10058
|
+
|
|
10059
|
+
function terminalShortcut(e) {
|
|
10060
|
+
const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
|
|
10061
|
+
if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
|
|
10062
|
+
if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
|
|
10063
|
+
return () => openNewSession(null, e.code === 'KeyR');
|
|
10064
|
+
}
|
|
10065
|
+
if (e.code !== 'Backquote' || e.metaKey) return null;
|
|
10066
|
+
if (e.ctrlKey && !e.altKey) return e.shiftKey ? openTerminalManager : toggleTerminal;
|
|
10067
|
+
if (e.altKey && !e.ctrlKey && !e.shiftKey) return toggleTerminalFocus;
|
|
10068
|
+
if (!e.altKey || !e.shiftKey || e.ctrlKey) return null;
|
|
10069
|
+
if (termState.attached && wantsTerminal()) return () => closeTerminalSession();
|
|
10070
|
+
if (viewMode === 'session' && runningTerminals.has(currentSessionId)) {
|
|
10071
|
+
return () => closeTerminalSession(currentSessionId);
|
|
10072
|
+
}
|
|
10073
|
+
return null;
|
|
10074
|
+
}
|
|
10075
|
+
|
|
10076
|
+
function swapToPreviousSession() {
|
|
10077
|
+
const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
|
|
10078
|
+
if (!target || target === currentSessionId) return;
|
|
10079
|
+
if (terminalPaneFocused()) termState.focusNext = true;
|
|
10080
|
+
openSession(target);
|
|
10081
|
+
}
|
|
10082
|
+
|
|
10083
|
+
// Esc belongs to Claude, so leaving the terminal without hiding it needs its own key.
|
|
10084
|
+
function toggleTerminalFocus() {
|
|
10085
|
+
if (!wantsTerminal()) {
|
|
10086
|
+
toggleTerminal();
|
|
10087
|
+
return;
|
|
10088
|
+
}
|
|
10089
|
+
if (terminalPaneFocused()) leaveTerminalPane();
|
|
10090
|
+
else focusTerminalPane();
|
|
10091
|
+
}
|
|
10092
|
+
|
|
10093
|
+
// Idempotent: runs on every view change, so whichever path changed the view lands here.
|
|
10094
|
+
function syncTerminal() {
|
|
10095
|
+
const btn = document.getElementById('terminal-toggle');
|
|
10096
|
+
const on = wantsTerminal();
|
|
10097
|
+
const arrived = viewMode === 'session' ? currentSessionId : null;
|
|
10098
|
+
const view = `${arrived}:${on}`;
|
|
10099
|
+
// Only on a change of session or terminal state, so a panel opened by hand over the terminal stays.
|
|
10100
|
+
if (view !== termState.syncedView) {
|
|
10101
|
+
termState.syncedView = view;
|
|
10102
|
+
// The terminal shows the same conversation, so the log panel only takes width; it comes back when the terminal goes.
|
|
10103
|
+
if (on && messagePanelOpen) {
|
|
10104
|
+
toggleMessagePanel();
|
|
10105
|
+
termState.panelHidden = true;
|
|
10106
|
+
} else if (!on && arrived && termState.panelHidden) toggleMessagePanel();
|
|
10107
|
+
}
|
|
10108
|
+
if (btn) {
|
|
10109
|
+
btn.style.display = terminalAvailable() && viewMode === 'session' && currentSessionId ? '' : 'none';
|
|
10110
|
+
btn.classList.toggle('active', on);
|
|
10111
|
+
}
|
|
10112
|
+
sessionView.classList.toggle('terminal-mode', on);
|
|
10113
|
+
if (!on) {
|
|
10114
|
+
termState.shown = false;
|
|
10115
|
+
termState.focusNext = false;
|
|
10116
|
+
termState.openedByUser = null;
|
|
10117
|
+
syncCloseGuard();
|
|
10118
|
+
// Hiding keeps the socket so Ctrl+` back is instant.
|
|
10119
|
+
const keepSocket = termState.sessionId === currentSessionId && viewMode === 'session';
|
|
10120
|
+
if (termState.sessionId && !keepSocket) detachTerminal();
|
|
10121
|
+
return;
|
|
10122
|
+
}
|
|
10123
|
+
const wasShown = termState.shown;
|
|
10124
|
+
termState.shown = true;
|
|
10125
|
+
syncCloseGuard();
|
|
10126
|
+
if (termState.sessionId !== currentSessionId) openTerminal(currentSessionId, terminalOpenMode(currentSessionId));
|
|
10127
|
+
else if (!wasShown) onTerminalShown(takeTerminalFocus(currentSessionId));
|
|
10128
|
+
}
|
|
10129
|
+
|
|
10130
|
+
// Only paths the user drives call this; restores, deep links and refreshes call fetchTasks, so they never take focus.
|
|
10131
|
+
function openSession(sessionId) {
|
|
10132
|
+
const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
|
|
10133
|
+
if (!alreadyOpen) termState.openedByUser = sessionId;
|
|
10134
|
+
else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
|
|
10135
|
+
return fetchTasks(sessionId);
|
|
10136
|
+
}
|
|
10137
|
+
|
|
10138
|
+
// Selecting a session only shows its terminal; focus follows an explicit toggle, a new session, or
|
|
10139
|
+
// the user opening a session that waits on a prompt, since answering it is why they opened it.
|
|
10140
|
+
function takeTerminalFocus(sessionId) {
|
|
10141
|
+
const opened = termState.openedByUser === sessionId;
|
|
10142
|
+
termState.openedByUser = null;
|
|
10143
|
+
const focus = termState.focusNext || newSpecs.has(sessionId) || (opened && promptAwaitsUser(sessionId));
|
|
10144
|
+
termState.focusNext = false;
|
|
10145
|
+
return focus;
|
|
10146
|
+
}
|
|
10147
|
+
|
|
10148
|
+
// The terminal attaches asynchronously; by then the user may be typing elsewhere or reading a modal.
|
|
10149
|
+
function promptAwaitsUser(sessionId) {
|
|
10150
|
+
if (!sessions.find((s) => s.id === sessionId)?.hasWaitingForUser || isAnyModalOpen()) return false;
|
|
10151
|
+
return !document.activeElement?.matches('input, textarea, select, [contenteditable]');
|
|
10152
|
+
}
|
|
10153
|
+
|
|
10154
|
+
function onTerminalShown(focus = true) {
|
|
10155
|
+
requestAnimationFrame(() => {
|
|
10156
|
+
fitTerminal();
|
|
10157
|
+
repaintTerminal();
|
|
10158
|
+
if (focus) focusTerminalPane();
|
|
10159
|
+
});
|
|
10160
|
+
}
|
|
10161
|
+
|
|
10162
|
+
function focusTerminalPane() {
|
|
10163
|
+
const prompt = document.getElementById('terminal-prompt');
|
|
10164
|
+
if (prompt.classList.contains('visible')) prompt.querySelector('button')?.focus();
|
|
10165
|
+
else termState.term?.focus();
|
|
10166
|
+
}
|
|
10167
|
+
|
|
10168
|
+
function leaveTerminalPane() {
|
|
10169
|
+
termState.leaving = true;
|
|
10170
|
+
document.activeElement.blur();
|
|
10171
|
+
termState.leaving = false;
|
|
10172
|
+
setFocusZone('sidebar');
|
|
10173
|
+
}
|
|
10174
|
+
|
|
10175
|
+
function loadXterm() {
|
|
10176
|
+
if (termState.loaded) return termState.loaded;
|
|
10177
|
+
const css = document.createElement('link');
|
|
10178
|
+
css.rel = 'stylesheet';
|
|
10179
|
+
css.href = '/vendor/xterm/xterm.css';
|
|
10180
|
+
document.head.appendChild(css);
|
|
10181
|
+
const script = (src) =>
|
|
10182
|
+
new Promise((resolve, reject) => {
|
|
10183
|
+
const s = document.createElement('script');
|
|
10184
|
+
s.src = src;
|
|
10185
|
+
s.onload = resolve;
|
|
10186
|
+
s.onerror = () => reject(new Error(`failed to load ${src}`));
|
|
10187
|
+
document.head.appendChild(s);
|
|
10188
|
+
});
|
|
10189
|
+
termState.loaded = script('/vendor/xterm/xterm.js').then(() =>
|
|
10190
|
+
Promise.all(['addon-fit', 'addon-webgl', 'addon-unicode11'].map((n) => script(`/vendor/xterm/${n}.js`))),
|
|
10191
|
+
);
|
|
10192
|
+
termState.loaded.catch(() => {
|
|
10193
|
+
termState.loaded = null;
|
|
10194
|
+
});
|
|
10195
|
+
return termState.loaded;
|
|
10196
|
+
}
|
|
10197
|
+
|
|
10198
|
+
function terminalTheme() {
|
|
10199
|
+
const css = getComputedStyle(document.body);
|
|
10200
|
+
const v = (name) => css.getPropertyValue(name).trim();
|
|
10201
|
+
const theme = {
|
|
10202
|
+
background: v('--bg-deep'),
|
|
10203
|
+
foreground: v('--text-primary'),
|
|
10204
|
+
cursor: v('--accent'),
|
|
10205
|
+
cursorAccent: v('--bg-deep'),
|
|
10206
|
+
selectionBackground: v('--accent-dim'),
|
|
10207
|
+
};
|
|
10208
|
+
for (const name of ANSI_COLOR_NAMES) {
|
|
10209
|
+
theme[name] = v(`--ansi-${name}`);
|
|
10210
|
+
theme[`bright${name[0].toUpperCase()}${name.slice(1)}`] = v(`--ansi-bright-${name}`);
|
|
10211
|
+
}
|
|
10212
|
+
return theme;
|
|
10213
|
+
}
|
|
10214
|
+
|
|
10215
|
+
function terminalThemeOptions() {
|
|
10216
|
+
return {
|
|
10217
|
+
theme: terminalTheme(),
|
|
10218
|
+
// Claude Code on a dark theme (pinned, or output from before it saw the OSC 11 reply) draws
|
|
10219
|
+
// fixed 256/truecolor values no palette reaches; 3 keeps them readable and still blue.
|
|
10220
|
+
minimumContrastRatio: isLightTheme() ? 3 : 1,
|
|
10221
|
+
};
|
|
10222
|
+
}
|
|
10223
|
+
|
|
10224
|
+
// Returning false hands the key back to the page: xterm skips it and the document
|
|
10225
|
+
// listeners (the toggle, hub forwarding, native paste) see it instead.
|
|
10226
|
+
function terminalKeyFilter(e) {
|
|
10227
|
+
if (e.type !== 'keydown') return true;
|
|
10228
|
+
const ctrlOnly = e.ctrlKey && !e.altKey && !e.metaKey;
|
|
10229
|
+
if (terminalShortcut(e)) return false;
|
|
10230
|
+
if (ctrlOnly && e.code === 'KeyC' && (e.shiftKey || termState.term.hasSelection())) {
|
|
10231
|
+
e.preventDefault();
|
|
10232
|
+
navigator.clipboard?.writeText(termState.term.getSelection()).catch(() => {});
|
|
10233
|
+
termState.term.clearSelection();
|
|
10234
|
+
return false;
|
|
10235
|
+
}
|
|
10236
|
+
if (ctrlOnly && e.code === 'KeyV') return false;
|
|
10237
|
+
return !isHubKey(e);
|
|
10238
|
+
}
|
|
10239
|
+
|
|
10240
|
+
function oscColor(hex) {
|
|
10241
|
+
const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
|
|
10242
|
+
if (!m) return null;
|
|
10243
|
+
return `rgb:${[0, 2, 4].map((i) => m[1].slice(i, i + 2).repeat(2)).join('/')}`;
|
|
10244
|
+
}
|
|
10245
|
+
|
|
10246
|
+
function ensureTerm() {
|
|
10247
|
+
if (termState.term) return termState.term;
|
|
10248
|
+
const cfg = appConfig.terminal;
|
|
10249
|
+
const host = document.getElementById('terminal-host');
|
|
10250
|
+
const fontFamily =
|
|
10251
|
+
cfg.fontFamily || getComputedStyle(document.body).getPropertyValue('--font-mono').trim() || 'monospace';
|
|
10252
|
+
const term = new window.Terminal({
|
|
10253
|
+
fontFamily,
|
|
10254
|
+
fontSize: cfg.fontSize,
|
|
10255
|
+
scrollback: cfg.scrollback,
|
|
10256
|
+
cursorBlink: true,
|
|
10257
|
+
allowProposedApi: true,
|
|
10258
|
+
...terminalThemeOptions(),
|
|
10259
|
+
});
|
|
10260
|
+
termState.fit = new window.FitAddon.FitAddon();
|
|
10261
|
+
term.loadAddon(termState.fit);
|
|
10262
|
+
term.loadAddon(new window.Unicode11Addon.Unicode11Addon());
|
|
10263
|
+
term.unicode.activeVersion = '11';
|
|
10264
|
+
term.open(host);
|
|
10265
|
+
try {
|
|
10266
|
+
const gl = new window.WebglAddon.WebglAddon();
|
|
10267
|
+
gl.onContextLoss(() => {
|
|
10268
|
+
gl.dispose();
|
|
10269
|
+
termState.webgl = null;
|
|
10270
|
+
repaintTerminal();
|
|
10271
|
+
});
|
|
10272
|
+
term.loadAddon(gl);
|
|
10273
|
+
termState.webgl = gl;
|
|
10274
|
+
} catch (_) {
|
|
10275
|
+
// No WebGL: xterm falls back to its DOM renderer.
|
|
10276
|
+
}
|
|
10277
|
+
document.fonts
|
|
10278
|
+
?.load(`${cfg.fontSize}px ${fontFamily}`)
|
|
10279
|
+
.then(repaintTerminal)
|
|
10280
|
+
.catch(() => {});
|
|
10281
|
+
term.attachCustomKeyEventHandler(terminalKeyFilter);
|
|
10282
|
+
// xterm leaves OSC 10/11 color queries unanswered, so apps that pick a palette from the
|
|
10283
|
+
// background (Claude Code's Auto theme) assume dark even on a light theme.
|
|
10284
|
+
for (const [code, key] of [
|
|
10285
|
+
[10, 'foreground'],
|
|
10286
|
+
[11, 'background'],
|
|
10287
|
+
]) {
|
|
10288
|
+
term.parser.registerOscHandler(code, (data) => {
|
|
10289
|
+
if (data !== '?') return false;
|
|
10290
|
+
const rgb = oscColor(term.options.theme[key]);
|
|
10291
|
+
if (rgb) terminalSend({ t: 'in', d: `\x1b]${code};${rgb}\x1b\\` });
|
|
10292
|
+
return true;
|
|
10293
|
+
});
|
|
10294
|
+
}
|
|
10295
|
+
term.onData((d) => terminalSend({ t: 'in', d }));
|
|
10296
|
+
term.onResize(({ cols, rows }) => terminalSend({ t: 'resize', cols, rows }));
|
|
10297
|
+
// One fit per frame while a drag resizes the host. The atlas is rebuilt only when the host comes
|
|
10298
|
+
// back from 0×0, which is also how a hidden hub iframe or pane shows again.
|
|
10299
|
+
let frame = 0;
|
|
10300
|
+
let visible = false;
|
|
10301
|
+
new ResizeObserver(() => {
|
|
10302
|
+
if (frame) return;
|
|
10303
|
+
frame = requestAnimationFrame(() => {
|
|
10304
|
+
frame = 0;
|
|
10305
|
+
const nowVisible = host.offsetWidth > 0;
|
|
10306
|
+
fitTerminal();
|
|
10307
|
+
if (nowVisible && !visible) repaintTerminal();
|
|
10308
|
+
visible = nowVisible;
|
|
10309
|
+
});
|
|
10310
|
+
}).observe(host);
|
|
10311
|
+
const themeKey = () => `${isLightTheme()}|${document.body.dataset.colorTheme || ''}`;
|
|
10312
|
+
let appliedTheme = themeKey();
|
|
10313
|
+
new MutationObserver(() => {
|
|
10314
|
+
if (themeKey() === appliedTheme) return;
|
|
10315
|
+
appliedTheme = themeKey();
|
|
10316
|
+
Object.assign(term.options, terminalThemeOptions());
|
|
10317
|
+
repaintTerminal();
|
|
10318
|
+
}).observe(document.body, { attributes: true, attributeFilter: ['class', 'data-color-theme'] });
|
|
10319
|
+
// Vimium eats Escape inside a text field and only blurs it, so the key never reaches Claude.
|
|
10320
|
+
// A blur no click caused, while the pane stays shown and the window keeps focus, is that Escape.
|
|
10321
|
+
let pointerDown = false;
|
|
10322
|
+
document.addEventListener('mousedown', () => (pointerDown = true), true);
|
|
10323
|
+
document.addEventListener('mouseup', () => (pointerDown = false), true);
|
|
10324
|
+
term.textarea.addEventListener('blur', () => {
|
|
10325
|
+
if (pointerDown || termState.leaving) return;
|
|
10326
|
+
requestAnimationFrame(() => {
|
|
10327
|
+
if (!termState.attached || !host.offsetWidth) return;
|
|
10328
|
+
if (!document.hasFocus() || document.activeElement !== document.body) return;
|
|
10329
|
+
terminalSend({ t: 'in', d: '\x1b' });
|
|
10330
|
+
term.focus();
|
|
10331
|
+
});
|
|
10332
|
+
});
|
|
10333
|
+
termState.term = term;
|
|
10334
|
+
return term;
|
|
10335
|
+
}
|
|
10336
|
+
|
|
10337
|
+
// The WebGL renderer keeps its last frame and glyph atlas while the pane (or the hub's iframe) is
|
|
10338
|
+
// display:none, and comes back showing stale cells or backgrounds with no text. xterm does not
|
|
10339
|
+
// repaint on its own when the canvas is shown again.
|
|
10340
|
+
function repaintTerminal() {
|
|
10341
|
+
const term = termState.term;
|
|
10342
|
+
if (!term) return;
|
|
10343
|
+
try {
|
|
10344
|
+
termState.webgl?.clearTextureAtlas();
|
|
10345
|
+
} catch (_) {}
|
|
10346
|
+
term.refresh(0, term.rows - 1);
|
|
10347
|
+
}
|
|
10348
|
+
|
|
10349
|
+
// The fit addon measures 0×0 while the pane (or the hub's iframe) is display:none.
|
|
10350
|
+
function fitTerminal() {
|
|
10351
|
+
const host = document.getElementById('terminal-host');
|
|
10352
|
+
if (!termState.fit || !host.offsetWidth || !host.offsetHeight) return;
|
|
10353
|
+
try {
|
|
10354
|
+
termState.fit.fit();
|
|
10355
|
+
} catch (_) {}
|
|
10356
|
+
}
|
|
10357
|
+
|
|
10358
|
+
function terminalSend(msg) {
|
|
10359
|
+
if (termState.ws?.readyState === WebSocket.OPEN) termState.ws.send(JSON.stringify(msg));
|
|
10360
|
+
}
|
|
10361
|
+
|
|
10362
|
+
function ackTerminal(ws, n) {
|
|
10363
|
+
if (termState.ws !== ws) return;
|
|
10364
|
+
termState.ackPending += n;
|
|
10365
|
+
const flush = () => {
|
|
10366
|
+
clearTimeout(termState.ackTimer);
|
|
10367
|
+
termState.ackTimer = null;
|
|
10368
|
+
if (termState.ws === ws && termState.ackPending) terminalSend({ t: 'ack', n: termState.ackPending });
|
|
10369
|
+
termState.ackPending = 0;
|
|
10370
|
+
};
|
|
10371
|
+
if (termState.ackPending >= ACK_BATCH_BYTES) flush();
|
|
10372
|
+
else if (!termState.ackTimer) termState.ackTimer = setTimeout(flush, 50);
|
|
10373
|
+
}
|
|
10374
|
+
|
|
10375
|
+
function setTerminalStatus(text) {
|
|
10376
|
+
document.getElementById('terminal-status').textContent = text;
|
|
10377
|
+
}
|
|
10378
|
+
|
|
10379
|
+
function hideTerminalPrompt() {
|
|
10380
|
+
document.getElementById('terminal-prompt').classList.remove('visible');
|
|
10381
|
+
}
|
|
10382
|
+
|
|
10383
|
+
const TERMINAL_BOOT_VERBS = ['Waking Claude…', 'Clawding…', 'Warming up the terminal…', 'Summoning the session…'];
|
|
10384
|
+
const TERMINAL_BOOT_MAX_MS = 15000;
|
|
10385
|
+
let terminalBootTimer = null;
|
|
10386
|
+
|
|
10387
|
+
// A new PTY shows only a blinking cursor until Claude draws its first frame.
|
|
10388
|
+
function showTerminalBoot() {
|
|
10389
|
+
document.getElementById('terminal-boot-verb').textContent = pickRandom(TERMINAL_BOOT_VERBS);
|
|
10390
|
+
document.getElementById('terminal-boot').classList.add('visible');
|
|
10391
|
+
clearTimeout(terminalBootTimer);
|
|
10392
|
+
terminalBootTimer = setTimeout(hideTerminalBoot, TERMINAL_BOOT_MAX_MS);
|
|
10393
|
+
}
|
|
10394
|
+
|
|
10395
|
+
function hideTerminalBoot() {
|
|
10396
|
+
clearTimeout(terminalBootTimer);
|
|
10397
|
+
terminalBootTimer = null;
|
|
10398
|
+
document.getElementById('terminal-boot').classList.remove('visible');
|
|
10399
|
+
}
|
|
10400
|
+
|
|
10401
|
+
function showTerminalPrompt(sessionId, title, detail, choices, output = '') {
|
|
10402
|
+
const el = document.getElementById('terminal-prompt');
|
|
10403
|
+
const tail = output ? `<pre class="${TINTED_PRE_CLASS} terminal-prompt-output">${escapeHtml(output)}</pre>` : '';
|
|
10404
|
+
el.innerHTML = `<div class="terminal-prompt-text"><div class="terminal-prompt-title">${escapeHtml(title)}</div><div>${escapeHtml(detail)}</div></div>${tail}<div class="terminal-prompt-actions">${choices
|
|
10405
|
+
.map(
|
|
10406
|
+
([mode, label]) =>
|
|
10407
|
+
`<button type="button" class="btn btn-secondary" data-mode="${escapeHtml(mode)}">${escapeHtml(label)}</button>`,
|
|
10408
|
+
)
|
|
10409
|
+
.join('')}</div><div class="sp-hint"><kbd>Esc</kbd> hides the terminal</div>`;
|
|
10410
|
+
hideTerminalBoot();
|
|
10411
|
+
el.querySelectorAll('button[data-mode]').forEach((b) => {
|
|
10412
|
+
b.onclick = () => openTerminal(sessionId, b.dataset.mode);
|
|
10413
|
+
});
|
|
10414
|
+
el.classList.add('visible');
|
|
10415
|
+
el.querySelector('button')?.focus();
|
|
10416
|
+
}
|
|
10417
|
+
|
|
10418
|
+
function terminalTail(count) {
|
|
10419
|
+
const buf = termState.term?.buffer.active;
|
|
10420
|
+
if (!buf) return '';
|
|
10421
|
+
const lines = [];
|
|
10422
|
+
for (let i = buf.length - 1; i >= 0 && lines.length < count; i--) {
|
|
10423
|
+
const text = buf.getLine(i)?.translateToString(true) ?? '';
|
|
10424
|
+
if (text.trim() || lines.length) lines.unshift(text);
|
|
10425
|
+
}
|
|
10426
|
+
return lines.join('\n');
|
|
10427
|
+
}
|
|
10428
|
+
|
|
10429
|
+
// Closing the page leaves the PTY running, but Ctrl+W meant for the prompt closes the tab, so ask first.
|
|
10430
|
+
// Under the hub the top frame asks: browsers do not reliably show the dialog for a frame.
|
|
10431
|
+
function setTerminalAttached(on) {
|
|
10432
|
+
termState.attached = on;
|
|
10433
|
+
syncCloseGuard();
|
|
10434
|
+
}
|
|
10435
|
+
|
|
10436
|
+
// A hidden pane stays attached, but Ctrl+W can only be meant for a terminal on screen.
|
|
10437
|
+
function syncCloseGuard() {
|
|
10438
|
+
const on = termState.attached && termState.shown;
|
|
10439
|
+
if (termState.closeGuard === on) return;
|
|
10440
|
+
termState.closeGuard = on;
|
|
10441
|
+
hubPost({ type: 'hub:closeGuard', on });
|
|
10442
|
+
}
|
|
10443
|
+
|
|
10444
|
+
window.addEventListener('beforeunload', (e) => {
|
|
10445
|
+
if (termState.closeGuard && !window.__HUB__?.enabled) e.preventDefault();
|
|
10446
|
+
});
|
|
10447
|
+
|
|
10448
|
+
// The terminal stands in for the board, so it takes the board zone however focus arrives
|
|
10449
|
+
// (Tab, Alt+`, a click); leaveTerminalPane hands it back to the sidebar.
|
|
10450
|
+
document.getElementById('terminal-pane').addEventListener('focusin', () => {
|
|
10451
|
+
if (focusZone !== 'sidebar') return;
|
|
10452
|
+
clearKbSelection();
|
|
10453
|
+
focusZone = 'board';
|
|
10454
|
+
});
|
|
10455
|
+
|
|
10456
|
+
function detachTerminal() {
|
|
10457
|
+
const ws = termState.ws;
|
|
10458
|
+
termState.ws = null;
|
|
10459
|
+
termState.sessionId = null;
|
|
10460
|
+
clearTimeout(termState.ackTimer);
|
|
10461
|
+
termState.ackTimer = null;
|
|
10462
|
+
termState.ackPending = 0;
|
|
10463
|
+
clearTimeout(termState.retryTimer);
|
|
10464
|
+
termState.retryTimer = null;
|
|
10465
|
+
setTerminalAttached(false);
|
|
10466
|
+
if (ws) {
|
|
10467
|
+
ws.onclose = null;
|
|
10468
|
+
ws.close();
|
|
10469
|
+
}
|
|
10470
|
+
hideTerminalPrompt();
|
|
10471
|
+
hideTerminalBoot();
|
|
10472
|
+
}
|
|
10473
|
+
|
|
10474
|
+
async function openTerminal(sessionId, mode, attempt = 0) {
|
|
10475
|
+
detachTerminal();
|
|
10476
|
+
termState.sessionId = sessionId;
|
|
10477
|
+
if (!attempt) setTerminalStatus('');
|
|
10478
|
+
try {
|
|
10479
|
+
await loadXterm();
|
|
10480
|
+
} catch (e) {
|
|
10481
|
+
setTerminalStatus(e.message);
|
|
10482
|
+
return;
|
|
10483
|
+
}
|
|
10484
|
+
if (termState.sessionId !== sessionId) return;
|
|
10485
|
+
const term = ensureTerm();
|
|
10486
|
+
if (!attempt) showTerminalBoot();
|
|
10487
|
+
// RIS through the write queue, not term.reset(): reset() runs at once, and output the previous
|
|
10488
|
+
// session had already queued would still be drawn after it. A retry keeps the last screen
|
|
10489
|
+
// until the replay arrives.
|
|
10490
|
+
const reset = () => term.write('\x1bc', repaintTerminal);
|
|
10491
|
+
if (!attempt) reset();
|
|
10492
|
+
fitTerminal();
|
|
10493
|
+
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/terminal/ws`);
|
|
10494
|
+
ws.binaryType = 'arraybuffer';
|
|
10495
|
+
termState.ws = ws;
|
|
10496
|
+
const spec = newSpecs.get(sessionId);
|
|
10497
|
+
let readyAt = 0;
|
|
10498
|
+
let refused = false;
|
|
10499
|
+
ws.onopen = () =>
|
|
10500
|
+
ws.send(
|
|
10501
|
+
JSON.stringify({
|
|
10502
|
+
t: 'hello',
|
|
10503
|
+
token: terminalToken,
|
|
10504
|
+
id: sessionId,
|
|
10505
|
+
mode,
|
|
10506
|
+
cols: term.cols,
|
|
10507
|
+
rows: term.rows,
|
|
10508
|
+
...(spec?.mode === mode && {
|
|
10509
|
+
cwd: spec.cwd,
|
|
10510
|
+
name: spec.name,
|
|
10511
|
+
worktree: spec.worktree,
|
|
10512
|
+
model: spec.model,
|
|
10513
|
+
prompt: spec.prompt,
|
|
10514
|
+
}),
|
|
10515
|
+
}),
|
|
10516
|
+
);
|
|
10517
|
+
ws.onmessage = (ev) => {
|
|
10518
|
+
if (termState.ws !== ws) return;
|
|
10519
|
+
if (typeof ev.data !== 'string') {
|
|
10520
|
+
const bytes = new Uint8Array(ev.data);
|
|
10521
|
+
term.write(bytes, () => {
|
|
10522
|
+
ackTerminal(ws, bytes.length);
|
|
10523
|
+
if (terminalBootTimer && termState.ws === ws && terminalTail(1)) hideTerminalBoot();
|
|
10524
|
+
});
|
|
10525
|
+
return;
|
|
10526
|
+
}
|
|
10527
|
+
let msg;
|
|
10528
|
+
try {
|
|
10529
|
+
msg = JSON.parse(ev.data);
|
|
10530
|
+
} catch (_) {
|
|
10531
|
+
return;
|
|
10532
|
+
}
|
|
10533
|
+
if (msg.t === 'ready') {
|
|
10534
|
+
readyAt = Date.now();
|
|
10535
|
+
if (attempt) reset();
|
|
10536
|
+
} else if (msg.t === 'error') refused = true;
|
|
10537
|
+
onTerminalMessage(sessionId, msg);
|
|
10538
|
+
};
|
|
10539
|
+
ws.onclose = (ev) => {
|
|
10540
|
+
if (termState.ws !== ws) return;
|
|
10541
|
+
termState.ws = null;
|
|
10542
|
+
setTerminalAttached(false);
|
|
10543
|
+
// A refusal already shows the server's reason, and retrying would only repeat it, unless the
|
|
10544
|
+
// reason was a token that the hub has since replaced (4001).
|
|
10545
|
+
if (refused && ev.code === 4001) {
|
|
10546
|
+
refreshTerminalToken().then((ok) => {
|
|
10547
|
+
if (ok && termState.sessionId === sessionId) openTerminal(sessionId, mode, attempt + 1);
|
|
10548
|
+
});
|
|
10549
|
+
return;
|
|
10550
|
+
}
|
|
10551
|
+
if (refused || document.getElementById('terminal-prompt').classList.contains('visible')) return;
|
|
10552
|
+
const stable = readyAt && Date.now() - readyAt > TERMINAL_STABLE_MS;
|
|
10553
|
+
retryTerminal(sessionId, stable ? 0 : attempt);
|
|
10554
|
+
};
|
|
10555
|
+
}
|
|
10556
|
+
|
|
10557
|
+
function terminalOpenMode(sessionId) {
|
|
10558
|
+
return newSpecs.get(sessionId)?.mode ?? 'auto';
|
|
10559
|
+
}
|
|
10560
|
+
|
|
10561
|
+
// The server moved the PTY to the session picked in `claude --resume`; reattaching under that id
|
|
10562
|
+
// replays the same screen, and the placeholder gives way to the real card.
|
|
10563
|
+
function adoptPickedSession(oldId, id) {
|
|
10564
|
+
const focused = terminalPaneFocused();
|
|
10565
|
+
if (lastSessionId === oldId) lastSessionId = id;
|
|
10566
|
+
if (previousSessionId === oldId) previousSessionId = id;
|
|
10567
|
+
setTerminalMode(id, true);
|
|
10568
|
+
forgetPlaceholder(oldId);
|
|
10569
|
+
if (currentSessionId !== oldId || viewMode !== 'session') return renderSessions();
|
|
10570
|
+
termState.focusNext = focused;
|
|
10571
|
+
fetchTasks(id);
|
|
10572
|
+
}
|
|
10573
|
+
|
|
10574
|
+
// The PTY outlives the socket, so a dropped connection (cck restart, sleep, proxy hiccup) reattaches
|
|
10575
|
+
// to the same screen. Only a socket that stayed up resets the backoff, so a server that accepts and
|
|
10576
|
+
// then drops every socket still runs out of attempts.
|
|
10577
|
+
function retryTerminal(sessionId, attempt) {
|
|
10578
|
+
const delay = TERMINAL_RETRY_MS[attempt];
|
|
10579
|
+
if (delay === undefined) {
|
|
10580
|
+
setTerminalStatus('');
|
|
10581
|
+
showTerminalPrompt(sessionId, 'Disconnected', 'The connection to the terminal was lost.', [
|
|
10582
|
+
[terminalOpenMode(sessionId), 'Reconnect'],
|
|
10583
|
+
]);
|
|
10584
|
+
return;
|
|
10585
|
+
}
|
|
10586
|
+
setTerminalStatus('Reconnecting…');
|
|
10587
|
+
termState.retryTimer = setTimeout(() => {
|
|
10588
|
+
termState.retryTimer = null;
|
|
10589
|
+
if (termState.sessionId === sessionId) openTerminal(sessionId, terminalOpenMode(sessionId), attempt + 1);
|
|
10590
|
+
}, delay);
|
|
10591
|
+
}
|
|
10592
|
+
|
|
10593
|
+
function onTerminalMessage(sessionId, msg) {
|
|
10594
|
+
if (msg.t === 'ready') {
|
|
10595
|
+
// The server has the prompt now; a later start of the same placeholder must not send it again.
|
|
10596
|
+
const spec = newSpecs.get(sessionId);
|
|
10597
|
+
if (spec) spec.prompt = null;
|
|
10598
|
+
hideTerminalPrompt();
|
|
10599
|
+
setTerminalStatus('');
|
|
10600
|
+
setTerminalAttached(true);
|
|
10601
|
+
terminalSend({ t: 'resize', cols: termState.term.cols, rows: termState.term.rows });
|
|
10602
|
+
if (takeTerminalFocus(sessionId)) termState.term.focus();
|
|
10603
|
+
} else if (msg.t === 'rekey' && typeof msg.id === 'string') {
|
|
10604
|
+
adoptPickedSession(sessionId, msg.id);
|
|
10605
|
+
if (msg.duplicate) showToast('That session is already open here, so this terminal switched to it', 'info');
|
|
10606
|
+
else if (msg.elsewhere)
|
|
10607
|
+
showToast('That session also runs in another terminal; both write the same transcript', 'error');
|
|
10608
|
+
} else if (msg.t === 'live') {
|
|
10609
|
+
showTerminalPrompt(
|
|
10610
|
+
sessionId,
|
|
10611
|
+
'Running in another terminal',
|
|
10612
|
+
'Resuming it here too makes both processes write to the same transcript.',
|
|
10613
|
+
[
|
|
10614
|
+
['resume', 'Resume anyway'],
|
|
10615
|
+
['fork', 'Fork'],
|
|
10616
|
+
['shell', 'Shell only'],
|
|
10617
|
+
],
|
|
10618
|
+
);
|
|
10619
|
+
} else if (msg.t === 'exit') {
|
|
10620
|
+
setTerminalAttached(false);
|
|
10621
|
+
// A failed exit shows its last lines in the prompt, so the cause stays readable.
|
|
10622
|
+
const clean = msg.code === 0 || msg.ended;
|
|
10623
|
+
// With no first message there is no transcript to resume, only the same new session to start again.
|
|
10624
|
+
const unsent = newSpecs.has(sessionId);
|
|
10625
|
+
if (unsent && clean) {
|
|
10626
|
+
dropPlaceholder(sessionId);
|
|
10627
|
+
return;
|
|
10628
|
+
}
|
|
10629
|
+
showTerminalPrompt(
|
|
10630
|
+
sessionId,
|
|
10631
|
+
msg.ended ? 'The terminal was ended' : 'The shell exited',
|
|
10632
|
+
clean ? 'Choose how to start again.' : `Exit code ${msg.code}.`,
|
|
10633
|
+
unsent
|
|
10634
|
+
? [
|
|
10635
|
+
[terminalOpenMode(sessionId), 'Start again'],
|
|
10636
|
+
['shell', 'Shell'],
|
|
10637
|
+
]
|
|
10638
|
+
: [
|
|
10639
|
+
['resume', 'Resume'],
|
|
10640
|
+
['fork', 'Fork'],
|
|
10641
|
+
['shell', 'Shell'],
|
|
10642
|
+
],
|
|
10643
|
+
clean ? '' : terminalTail(6),
|
|
10644
|
+
);
|
|
10645
|
+
} else if (msg.t === 'error') {
|
|
10646
|
+
hideTerminalBoot();
|
|
10647
|
+
setTerminalStatus(msg.msg || 'Terminal error');
|
|
10648
|
+
}
|
|
10649
|
+
}
|
|
10650
|
+
|
|
10651
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
10652
|
+
function endTerminalSession() {
|
|
10653
|
+
terminalSend({ t: 'kill' });
|
|
10654
|
+
}
|
|
10655
|
+
|
|
10656
|
+
// Detaching first means no exit message reaches the pane, so no Resume prompt shows before the board.
|
|
10657
|
+
function closeTerminalSession(id = termState.sessionId) {
|
|
10658
|
+
if (id === termState.sessionId) {
|
|
10659
|
+
if (terminalPaneFocused()) leaveTerminalPane();
|
|
10660
|
+
detachTerminal();
|
|
10661
|
+
}
|
|
10662
|
+
setTerminalMode(id, false);
|
|
10663
|
+
syncTerminal();
|
|
10664
|
+
return endTerminal(id);
|
|
10665
|
+
}
|
|
10666
|
+
|
|
10667
|
+
function openTerminalManager() {
|
|
10668
|
+
if (!terminalAvailable()) return;
|
|
10669
|
+
// Esc typed in xterm goes to Claude, so the modal would not close on it.
|
|
10670
|
+
if (terminalPaneFocused()) leaveTerminalPane();
|
|
10671
|
+
document.getElementById('terminal-manager-modal').classList.add('visible');
|
|
10672
|
+
renderTerminalManager();
|
|
10673
|
+
}
|
|
10674
|
+
|
|
10675
|
+
function closeTerminalManager() {
|
|
10676
|
+
hideModalOverlay('terminal-manager-modal');
|
|
10677
|
+
}
|
|
10678
|
+
|
|
10679
|
+
async function renderTerminalManager() {
|
|
10680
|
+
const body = document.getElementById('terminal-manager-body');
|
|
10681
|
+
let list = [];
|
|
10682
|
+
try {
|
|
10683
|
+
list = await loadTerminals();
|
|
10684
|
+
} catch (e) {
|
|
10685
|
+
body.innerHTML = `<div class="terminal-manager-empty">${escapeHtml(e.message)}</div>`;
|
|
10686
|
+
return;
|
|
10687
|
+
}
|
|
10688
|
+
const max = appConfig.terminal?.maxSessions;
|
|
10689
|
+
document.getElementById('terminal-manager-count').textContent = max
|
|
10690
|
+
? `${list.length} of ${max} running`
|
|
10691
|
+
: `${list.length} running`;
|
|
10692
|
+
document.getElementById('terminal-manager-end-all').hidden = list.length < 2;
|
|
10693
|
+
if (!list.length) {
|
|
10694
|
+
body.innerHTML = '<div class="terminal-manager-empty">No terminals running</div>';
|
|
10695
|
+
return;
|
|
10696
|
+
}
|
|
10697
|
+
list.sort((a, b) => b.startedAt - a.startedAt);
|
|
10698
|
+
body.innerHTML = list
|
|
10699
|
+
.map((t) => {
|
|
10700
|
+
const session = sessions.find((s) => s.id === t.id);
|
|
10701
|
+
const name = session ? sessionDisplayName(session) : t.id.slice(0, 8);
|
|
10702
|
+
const here = t.id === termState.sessionId ? '<span class="terminal-manager-here">this tab</span>' : '';
|
|
10703
|
+
const attached = t.clients ? `${t.clients} attached` : 'detached';
|
|
10704
|
+
return `<div class="terminal-manager-row${t.clients ? ' attached' : ''}">
|
|
10705
|
+
<span class="terminal-manager-dot"></span>
|
|
10706
|
+
<div class="terminal-manager-info">
|
|
10707
|
+
<div class="terminal-manager-name"><span class="terminal-manager-title">${escapeHtml(name)}</span>${here}</div>
|
|
10708
|
+
<div class="terminal-manager-meta">
|
|
10709
|
+
<span class="terminal-manager-mode">${escapeHtml(t.mode)}</span>
|
|
10710
|
+
<span title="${escapeHtml(`pid ${t.pid}`)}">up ${formatDuration(Date.now() - t.startedAt)}</span>
|
|
10711
|
+
<span>${attached}</span>
|
|
10712
|
+
<span class="terminal-manager-cwd" title="${escapeHtml(t.cwd)}">${escapeHtml(pathBasename(t.cwd))}</span>
|
|
10713
|
+
</div>
|
|
10714
|
+
</div>
|
|
10715
|
+
<div class="terminal-manager-actions">
|
|
10716
|
+
<button type="button" class="btn btn-secondary" data-open="${escapeHtml(t.id)}">Open</button>
|
|
10717
|
+
<button type="button" class="btn btn-secondary terminal-manager-end" data-end="${escapeHtml(t.id)}">End</button>
|
|
10718
|
+
</div>
|
|
10719
|
+
</div>`;
|
|
10720
|
+
})
|
|
10721
|
+
.join('');
|
|
10722
|
+
body.querySelectorAll('[data-open]').forEach((b) => {
|
|
10723
|
+
b.onclick = () => {
|
|
10724
|
+
closeTerminalManager();
|
|
10725
|
+
showSessionTerminal(b.dataset.open);
|
|
10726
|
+
};
|
|
10727
|
+
});
|
|
10728
|
+
body.querySelectorAll('[data-end]').forEach((b) => {
|
|
10729
|
+
b.onclick = async () => {
|
|
10730
|
+
b.disabled = true;
|
|
10731
|
+
await endTerminal(b.dataset.end);
|
|
10732
|
+
renderTerminalManager();
|
|
10733
|
+
};
|
|
10734
|
+
});
|
|
10735
|
+
}
|
|
10736
|
+
|
|
10737
|
+
async function terminalFetch(url, method, body) {
|
|
10738
|
+
const send = () =>
|
|
10739
|
+
fetch(url, {
|
|
10740
|
+
method,
|
|
10741
|
+
headers: {
|
|
10742
|
+
'X-Terminal-Token': terminalToken || '',
|
|
10743
|
+
...(body && { 'Content-Type': 'application/json' }),
|
|
10744
|
+
},
|
|
10745
|
+
...(body && { body: JSON.stringify(body) }),
|
|
10746
|
+
});
|
|
10747
|
+
const res = await send();
|
|
10748
|
+
return res.status === 401 && (await refreshTerminalToken()) ? send() : res;
|
|
10749
|
+
}
|
|
10750
|
+
|
|
10751
|
+
async function endTerminal(id) {
|
|
10752
|
+
await terminalFetch(`/api/terminals/${encodeURIComponent(id)}`, 'DELETE').catch(() => {});
|
|
10753
|
+
dropPlaceholder(id);
|
|
10754
|
+
}
|
|
10755
|
+
|
|
10756
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
10757
|
+
async function endAllTerminals() {
|
|
10758
|
+
const buttons = document.querySelectorAll('#terminal-manager-body [data-end]');
|
|
10759
|
+
await Promise.all([...buttons].map((b) => endTerminal(b.dataset.end)));
|
|
10760
|
+
renderTerminalManager();
|
|
10761
|
+
}
|
|
10762
|
+
|
|
10763
|
+
//#endregion
|
|
10764
|
+
|
|
10765
|
+
//#region NEW_SESSION
|
|
10766
|
+
// claude writes no transcript until the first message, so until then the sidebar shows a
|
|
10767
|
+
// placeholder built from the form. newSpecs holds one entry per such session; the entry
|
|
10768
|
+
// goes when the real session shows up in /api/sessions.
|
|
10769
|
+
const NEW_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$/;
|
|
10770
|
+
const NEW_WORKTREE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
10771
|
+
const NS_MAX_MATCHES = 8;
|
|
10772
|
+
const PLUS_SVG =
|
|
10773
|
+
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
|
|
10774
|
+
const newSpecs = new Map();
|
|
10775
|
+
// Sessions another session started (`dispatch start`), shown where they will stay until
|
|
10776
|
+
// their transcript appears. Kept apart from newSpecs: their prompt is already sent.
|
|
10777
|
+
const dispatchSpecs = new Map();
|
|
10778
|
+
const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', browsing: false, resume: false };
|
|
10779
|
+
|
|
10780
|
+
const PLACEHOLDER_NAMES = { pick: 'Resume session', dispatch: 'Started session', new: 'New session' };
|
|
10781
|
+
const PLACEHOLDER_HINTS = {
|
|
10782
|
+
pick: 'pick a session to resume',
|
|
10783
|
+
dispatch: 'starting',
|
|
10784
|
+
new: 'waiting for your first message',
|
|
10785
|
+
};
|
|
10786
|
+
|
|
10787
|
+
function placeholderSession(id, spec) {
|
|
10788
|
+
return {
|
|
10789
|
+
id,
|
|
10790
|
+
placeholder: true,
|
|
10791
|
+
mode: spec.mode,
|
|
10792
|
+
name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
10793
|
+
dispatchGroup: spec.group || undefined,
|
|
10794
|
+
startedBy: spec.startedBy || undefined,
|
|
10795
|
+
project: spec.cwd,
|
|
10796
|
+
worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
|
|
10797
|
+
modifiedAt: new Date(spec.startedAt).toISOString(),
|
|
10798
|
+
hasMessages: true,
|
|
10799
|
+
hasRecentActivity: true,
|
|
10800
|
+
taskCount: 0,
|
|
10801
|
+
completed: 0,
|
|
10802
|
+
};
|
|
10803
|
+
}
|
|
10804
|
+
|
|
10805
|
+
function mergePlaceholders(list) {
|
|
10806
|
+
if (!newSpecs.size && !dispatchSpecs.size) return list;
|
|
10807
|
+
const ids = new Set(list.map((s) => s.id));
|
|
10808
|
+
for (const specs of [newSpecs, dispatchSpecs]) {
|
|
10809
|
+
for (const id of [...specs.keys()]) if (ids.has(id)) specs.delete(id);
|
|
10810
|
+
}
|
|
10811
|
+
const pending = [...newSpecs, ...dispatchSpecs].sort((a, b) => b[1].startedAt - a[1].startedAt);
|
|
10812
|
+
return [...pending.map(([id, spec]) => placeholderSession(id, spec)), ...list];
|
|
10813
|
+
}
|
|
10814
|
+
|
|
10815
|
+
async function loadDispatches() {
|
|
10816
|
+
try {
|
|
10817
|
+
const res = await fetch('/api/dispatch', { cache: 'no-store' });
|
|
10818
|
+
const { running = [] } = await res.json();
|
|
10819
|
+
dispatchSpecs.clear();
|
|
10820
|
+
for (const r of running) {
|
|
10821
|
+
if (!r.session || sessions.some((s) => s.id === r.session && !s.placeholder)) continue;
|
|
10822
|
+
dispatchSpecs.set(r.session, {
|
|
10823
|
+
mode: 'dispatch',
|
|
10824
|
+
cwd: r.cwd,
|
|
10825
|
+
name: r.name,
|
|
10826
|
+
worktree: r.worktree,
|
|
10827
|
+
group: r.group,
|
|
10828
|
+
startedBy: r.parent,
|
|
10829
|
+
startedAt: r.startedAt,
|
|
10830
|
+
});
|
|
10831
|
+
}
|
|
10832
|
+
} catch (_) {}
|
|
10833
|
+
}
|
|
10834
|
+
|
|
10835
|
+
async function onDispatchUpdate() {
|
|
10836
|
+
await loadDispatches();
|
|
10837
|
+
sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
|
|
10838
|
+
renderSessions();
|
|
10839
|
+
fetchSessions(false).catch(() => {});
|
|
10840
|
+
}
|
|
10841
|
+
|
|
10842
|
+
// With no first message there is no transcript, so an ended placeholder leaves nothing to come back to.
|
|
10843
|
+
function dropPlaceholder(id) {
|
|
10844
|
+
if (!forgetPlaceholder(id)) return;
|
|
10845
|
+
if (currentSessionId === id && viewMode === 'session') showAllTasks();
|
|
10846
|
+
else renderSessions();
|
|
10847
|
+
}
|
|
10848
|
+
|
|
10849
|
+
function forgetPlaceholder(id) {
|
|
10850
|
+
if (!newSpecs.delete(id)) return false;
|
|
10851
|
+
setTerminalMode(id, false);
|
|
10852
|
+
// Before the socket's close handler runs, or it would reconnect and start the session again.
|
|
10853
|
+
if (termState.sessionId === id) detachTerminal();
|
|
10854
|
+
sessions = sessions.filter((s) => s.id !== id);
|
|
10855
|
+
if (previousSessionId === id) previousSessionId = null;
|
|
10856
|
+
if (lastSessionId === id) lastSessionId = null;
|
|
10857
|
+
return true;
|
|
10858
|
+
}
|
|
10859
|
+
|
|
10860
|
+
function renderPlaceholderCard(session) {
|
|
10861
|
+
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
10862
|
+
const projectHtml = renderProjectIdentity(session);
|
|
10863
|
+
return `
|
|
10864
|
+
<button onclick="fetchTasks('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
|
|
10865
|
+
<div class="session-name">${escapeHtml(session.name)}</div>
|
|
10866
|
+
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
10867
|
+
<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.mode] || PLACEHOLDER_HINTS.new}</div>
|
|
10868
|
+
</button>
|
|
10869
|
+
`;
|
|
10870
|
+
}
|
|
10871
|
+
|
|
10872
|
+
// After a reload the form is gone, but the PTY still runs; its hello options come back from the server.
|
|
10873
|
+
async function restorePendingSessions() {
|
|
10874
|
+
document.getElementById('new-session-btn').hidden = !terminalAvailable();
|
|
10875
|
+
const terminals = terminalAvailable() ? loadTerminals() : null;
|
|
10876
|
+
await loadDispatches();
|
|
10877
|
+
if (!terminals) return;
|
|
10878
|
+
try {
|
|
10879
|
+
for (const t of await terminals) {
|
|
10880
|
+
if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
|
|
10881
|
+
newSpecs.set(t.id, { cwd: t.cwd, name: t.name, worktree: t.worktree, mode: t.mode, startedAt: t.startedAt });
|
|
10882
|
+
}
|
|
10883
|
+
} catch (_) {}
|
|
10884
|
+
}
|
|
10885
|
+
|
|
10886
|
+
async function loadTerminals() {
|
|
10887
|
+
const res = await fetch('/api/terminals', { cache: 'no-store' });
|
|
10888
|
+
const list = (await res.json()).sessions || [];
|
|
10889
|
+
runningTerminals = new Set(list.map((t) => t.id));
|
|
10890
|
+
return list;
|
|
10891
|
+
}
|
|
10892
|
+
|
|
10893
|
+
function setRunningTerminals(ids) {
|
|
10894
|
+
runningTerminals = new Set(ids);
|
|
10895
|
+
if (ids.some((id) => !sessions.some((s) => s.id === id))) fetchSessions(false).catch(() => {});
|
|
10896
|
+
else renderSessions();
|
|
10897
|
+
}
|
|
10898
|
+
|
|
10899
|
+
function showSessionTerminal(sessionId) {
|
|
10900
|
+
setTerminalMode(sessionId, true);
|
|
10901
|
+
if (currentSessionId === sessionId && viewMode === 'session') syncTerminal();
|
|
10902
|
+
else fetchTasks(sessionId);
|
|
10903
|
+
}
|
|
10904
|
+
|
|
10905
|
+
function openNewSession(folder, resume = false) {
|
|
10906
|
+
if (!terminalAvailable() || document.getElementById('new-session-modal').classList.contains('visible')) return;
|
|
10907
|
+
if (terminalPaneFocused()) leaveTerminalPane();
|
|
10908
|
+
ns.resume = resume;
|
|
10909
|
+
document.querySelector('.new-session-modal').classList.toggle('ns-resume', resume);
|
|
10910
|
+
document.getElementById('ns-title').textContent = resume ? 'Resume session' : 'New session';
|
|
10911
|
+
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt']) document.getElementById(id).value = '';
|
|
10912
|
+
document.getElementById('ns-wt').checked = false;
|
|
10913
|
+
document.getElementById('ns-model').value = '';
|
|
10914
|
+
setNewSessionError('');
|
|
10915
|
+
const current = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
|
|
10916
|
+
setNewSessionFolder(folder || current || '');
|
|
10917
|
+
document.getElementById('new-session-modal').classList.add('visible');
|
|
10918
|
+
const input = document.getElementById('ns-folder');
|
|
10919
|
+
input.focus();
|
|
10920
|
+
input.select();
|
|
10921
|
+
loadProjects().then((list) => {
|
|
10922
|
+
ns.projects = list
|
|
10923
|
+
.filter((p) => !p.temp)
|
|
10924
|
+
.sort((a, b) => (b.modifiedAt || '').localeCompare(a.modifiedAt || ''))
|
|
10925
|
+
.map((p) => p.path);
|
|
10926
|
+
if (!ns.folder) setNewSessionFolder(input.value, true);
|
|
10927
|
+
if (folderListOpen()) renderFolderList();
|
|
10928
|
+
});
|
|
10929
|
+
}
|
|
10930
|
+
|
|
10931
|
+
function closeNewSession() {
|
|
10932
|
+
hideModalOverlay('new-session-modal');
|
|
10933
|
+
closeFolderList();
|
|
10934
|
+
promptVoice.rec?.abort();
|
|
10935
|
+
}
|
|
10936
|
+
|
|
10937
|
+
function knownFolders() {
|
|
10938
|
+
return [...new Set([...ns.picked, ...ns.projects])];
|
|
10939
|
+
}
|
|
10940
|
+
|
|
10941
|
+
// The field holds free text; a folder counts as chosen only when the text is a known project
|
|
10942
|
+
// or a folder the dialog returned, because those are the only ones the server accepts.
|
|
10943
|
+
// The sidebar's projects count too, so a prefilled folder is usable before /api/projects answers.
|
|
10944
|
+
function setNewSessionFolder(value, keepList) {
|
|
10945
|
+
const input = document.getElementById('ns-folder');
|
|
10946
|
+
input.value = value;
|
|
10947
|
+
const known = knownFolders().includes(value) || sessions.some((s) => !s.placeholder && s.project === value);
|
|
10948
|
+
ns.folder = value && known ? value : '';
|
|
10949
|
+
if (!keepList) closeFolderList();
|
|
10950
|
+
renderNewSessionForm();
|
|
10951
|
+
}
|
|
10952
|
+
|
|
10953
|
+
function folderRank(path, q) {
|
|
10954
|
+
const name = pathBasename(path).toLowerCase();
|
|
10955
|
+
if (name === q) return 0;
|
|
10956
|
+
if (name.startsWith(q)) return 1;
|
|
10957
|
+
if (name.includes(q)) return 2;
|
|
10958
|
+
return path.toLowerCase().includes(q) ? 3 : 4;
|
|
10959
|
+
}
|
|
10960
|
+
|
|
10961
|
+
function rankFolders(paths, query) {
|
|
10962
|
+
const q = query.toLowerCase();
|
|
10963
|
+
return paths
|
|
10964
|
+
.filter((p) => fuzzyMatch(p, query))
|
|
10965
|
+
.map((p) => ({ p, r: folderRank(p, q) }))
|
|
10966
|
+
.sort((a, b) => a.r - b.r)
|
|
10967
|
+
.map((x) => x.p);
|
|
10968
|
+
}
|
|
10969
|
+
|
|
10970
|
+
function renderFolderList() {
|
|
10971
|
+
const input = document.getElementById('ns-folder');
|
|
10972
|
+
const list = document.getElementById('ns-folder-list');
|
|
10973
|
+
const query = input.value.trim();
|
|
10974
|
+
const all = knownFolders();
|
|
10975
|
+
ns.matches = (query && query !== ns.folder ? rankFolders(all, query) : all).slice(0, NS_MAX_MATCHES);
|
|
10976
|
+
if (ns.idx >= ns.matches.length) ns.idx = ns.matches.length - 1;
|
|
10977
|
+
list.innerHTML = ns.matches.length
|
|
10978
|
+
? ns.matches
|
|
10979
|
+
.map(
|
|
10980
|
+
(p, i) =>
|
|
10981
|
+
`<div class="ns-option${i === ns.idx ? ' active' : ''}" role="option" data-idx="${i}" aria-selected="${i === ns.idx}"><span class="ns-option-name">${escapeHtml(pathBasename(p))}</span><span class="ns-option-path">${escapeHtml(p)}</span></div>`,
|
|
10982
|
+
)
|
|
10983
|
+
.join('')
|
|
10984
|
+
: '<div class="ns-empty"><span>No project matches</span><button type="button" class="btn btn-secondary ns-browse" data-browse>Browse…</button></div>';
|
|
10985
|
+
list.hidden = false;
|
|
10986
|
+
input.setAttribute('aria-expanded', 'true');
|
|
10987
|
+
}
|
|
10988
|
+
|
|
10989
|
+
function moveFolderHighlight(step) {
|
|
10990
|
+
const n = ns.matches.length;
|
|
10991
|
+
if (!n) return;
|
|
10992
|
+
ns.idx = (ns.idx + step + n) % n;
|
|
10993
|
+
for (const row of document.querySelectorAll('#ns-folder-list .ns-option')) {
|
|
10994
|
+
const on = Number(row.dataset.idx) === ns.idx;
|
|
10995
|
+
row.classList.toggle('active', on);
|
|
10996
|
+
row.setAttribute('aria-selected', on);
|
|
10997
|
+
}
|
|
10998
|
+
}
|
|
10999
|
+
|
|
11000
|
+
function closeFolderList() {
|
|
11001
|
+
document.getElementById('ns-folder-list').hidden = true;
|
|
11002
|
+
document.getElementById('ns-folder').setAttribute('aria-expanded', 'false');
|
|
11003
|
+
ns.idx = -1;
|
|
11004
|
+
}
|
|
11005
|
+
|
|
11006
|
+
function folderListOpen() {
|
|
11007
|
+
return !document.getElementById('ns-folder-list').hidden;
|
|
11008
|
+
}
|
|
11009
|
+
|
|
11010
|
+
function newSessionValues() {
|
|
11011
|
+
const wtOn = document.getElementById('ns-wt').checked;
|
|
11012
|
+
const wtName = document.getElementById('ns-wt-name').value.trim();
|
|
11013
|
+
return {
|
|
11014
|
+
cwd: ns.folder,
|
|
11015
|
+
name: document.getElementById('ns-name').value.trim() || null,
|
|
11016
|
+
worktree: wtOn ? wtName || true : false,
|
|
11017
|
+
model: document.getElementById('ns-model').value || null,
|
|
11018
|
+
prompt: document.getElementById('ns-prompt').value.trim() || null,
|
|
11019
|
+
};
|
|
11020
|
+
}
|
|
11021
|
+
|
|
11022
|
+
function newSessionProblem(v) {
|
|
11023
|
+
if (v.name && !NEW_NAME_RE.test(v.name))
|
|
11024
|
+
return 'Name: use letters, digits, spaces and . _ -, starting with a letter or digit.';
|
|
11025
|
+
if (typeof v.worktree === 'string' && !NEW_WORKTREE_RE.test(v.worktree))
|
|
11026
|
+
return 'Worktree name: use letters, digits and . _ -, starting with a letter or digit.';
|
|
11027
|
+
return '';
|
|
11028
|
+
}
|
|
11029
|
+
|
|
11030
|
+
function setNewSessionError(text) {
|
|
11031
|
+
const el = document.getElementById('ns-error');
|
|
11032
|
+
el.textContent = text;
|
|
11033
|
+
el.hidden = !text;
|
|
11034
|
+
}
|
|
11035
|
+
|
|
11036
|
+
function renderNewSessionForm() {
|
|
11037
|
+
const v = newSessionValues();
|
|
11038
|
+
const wtOn = v.worktree !== false;
|
|
11039
|
+
const wtInput = document.getElementById('ns-wt-name');
|
|
11040
|
+
wtInput.disabled = !wtOn;
|
|
11041
|
+
const hint = document.getElementById('ns-wt-hint');
|
|
11042
|
+
hint.hidden = !wtOn;
|
|
11043
|
+
hint.textContent = `.claude/worktrees/${typeof v.worktree === 'string' ? v.worktree : '<name chosen by Claude>'}`;
|
|
11044
|
+
const problem = newSessionProblem(v);
|
|
11045
|
+
setNewSessionError(problem);
|
|
11046
|
+
const start = document.getElementById('ns-start');
|
|
11047
|
+
start.disabled = !v.cwd || !!problem || ns.browsing;
|
|
11048
|
+
const verb = ns.resume ? 'Resume' : 'Start';
|
|
11049
|
+
start.textContent = v.cwd ? `${verb} in ${pathBasename(v.cwd)}` : verb;
|
|
11050
|
+
}
|
|
11051
|
+
|
|
11052
|
+
async function browseNewSessionFolder() {
|
|
11053
|
+
if (ns.browsing) return;
|
|
11054
|
+
ns.browsing = true;
|
|
11055
|
+
closeFolderList();
|
|
11056
|
+
const btn = document.getElementById('ns-browse');
|
|
11057
|
+
btn.textContent = 'Opening…';
|
|
11058
|
+
renderNewSessionForm();
|
|
11059
|
+
try {
|
|
11060
|
+
const start = ns.folder || ns.picked[0] || ns.projects[0];
|
|
11061
|
+
const res = await terminalFetch('/api/terminal/pick-folder', 'POST', start ? { start } : null);
|
|
11062
|
+
const body = await res.json().catch(() => ({}));
|
|
11063
|
+
if (res.status === 401) setNewSessionError(STALE_TOKEN_MSG);
|
|
11064
|
+
else if (!res.ok) setNewSessionError(body.error || `Folder dialog failed (${res.status})`);
|
|
11065
|
+
else if (body.path) {
|
|
11066
|
+
ns.picked = [body.path, ...ns.picked.filter((p) => p !== body.path)];
|
|
11067
|
+
setNewSessionFolder(body.path);
|
|
11068
|
+
}
|
|
11069
|
+
} catch (e) {
|
|
11070
|
+
setNewSessionError(e.message);
|
|
11071
|
+
} finally {
|
|
11072
|
+
ns.browsing = false;
|
|
11073
|
+
btn.textContent = 'Browse…';
|
|
11074
|
+
renderNewSessionForm();
|
|
11075
|
+
document.getElementById(ns.folder ? fieldAfterFolder() : 'ns-folder').focus();
|
|
11076
|
+
}
|
|
11077
|
+
}
|
|
11078
|
+
|
|
11079
|
+
function fieldAfterFolder() {
|
|
11080
|
+
return ns.resume ? 'ns-start' : 'ns-name';
|
|
11081
|
+
}
|
|
11082
|
+
|
|
11083
|
+
function startNewSession() {
|
|
11084
|
+
const v = newSessionValues();
|
|
11085
|
+
if (!v.cwd || newSessionProblem(v) || ns.browsing) return;
|
|
11086
|
+
const id = crypto.randomUUID();
|
|
11087
|
+
newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
|
|
11088
|
+
sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
|
|
11089
|
+
setTerminalMode(id, true);
|
|
11090
|
+
closeNewSession();
|
|
11091
|
+
fetchTasks(id).then(() => sessionsList.querySelector('.session-item.active')?.scrollIntoView({ block: 'nearest' }));
|
|
11092
|
+
}
|
|
11093
|
+
|
|
11094
|
+
const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
11095
|
+
const promptVoice = { rec: null };
|
|
11096
|
+
|
|
11097
|
+
// Replaces the selection the field had when dictation began, so interim results rewrite
|
|
11098
|
+
// themselves in place instead of piling up.
|
|
11099
|
+
function togglePromptVoice() {
|
|
11100
|
+
if (promptVoice.rec) return promptVoice.rec.stop();
|
|
11101
|
+
const field = document.getElementById('ns-prompt');
|
|
11102
|
+
const btn = document.getElementById('ns-mic');
|
|
11103
|
+
const before = field.value.slice(0, field.selectionStart);
|
|
11104
|
+
const after = field.value.slice(field.selectionEnd);
|
|
11105
|
+
const lead = before && !/\s$/.test(before) ? ' ' : '';
|
|
11106
|
+
const rec = new SpeechRecognitionApi();
|
|
11107
|
+
rec.lang = navigator.language;
|
|
11108
|
+
rec.interimResults = true;
|
|
11109
|
+
rec.onresult = (e) => {
|
|
11110
|
+
const text = Array.from(e.results, (r) => r[0].transcript).join('');
|
|
11111
|
+
field.value = before + lead + text + after;
|
|
11112
|
+
const caret = before.length + lead.length + text.length;
|
|
11113
|
+
field.setSelectionRange(caret, caret);
|
|
11114
|
+
};
|
|
11115
|
+
rec.onerror = (e) => {
|
|
11116
|
+
if (e.error === 'not-allowed' || e.error === 'service-not-allowed')
|
|
11117
|
+
setNewSessionError('Microphone access is blocked for this page.');
|
|
11118
|
+
else if (e.error !== 'no-speech' && e.error !== 'aborted') setNewSessionError(`Dictation failed: ${e.error}`);
|
|
11119
|
+
};
|
|
11120
|
+
rec.onend = () => {
|
|
11121
|
+
promptVoice.rec = null;
|
|
11122
|
+
btn.setAttribute('aria-pressed', 'false');
|
|
11123
|
+
};
|
|
11124
|
+
promptVoice.rec = rec;
|
|
11125
|
+
btn.setAttribute('aria-pressed', 'true');
|
|
11126
|
+
field.focus();
|
|
11127
|
+
rec.start();
|
|
11128
|
+
}
|
|
11129
|
+
|
|
11130
|
+
function pickFolderOption(i) {
|
|
11131
|
+
const path = ns.matches[i];
|
|
11132
|
+
if (!path) return;
|
|
11133
|
+
setNewSessionFolder(path);
|
|
11134
|
+
document.getElementById(fieldAfterFolder()).focus();
|
|
11135
|
+
}
|
|
11136
|
+
|
|
11137
|
+
function initNewSession() {
|
|
11138
|
+
const modal = document.getElementById('new-session-modal');
|
|
11139
|
+
const input = document.getElementById('ns-folder');
|
|
11140
|
+
const list = document.getElementById('ns-folder-list');
|
|
11141
|
+
input.addEventListener('input', () => {
|
|
11142
|
+
setNewSessionFolder(input.value, true);
|
|
11143
|
+
if (ns.folder) return closeFolderList();
|
|
11144
|
+
ns.idx = 0;
|
|
11145
|
+
renderFolderList();
|
|
11146
|
+
});
|
|
11147
|
+
input.addEventListener('focus', () => {
|
|
11148
|
+
if (!ns.folder) renderFolderList();
|
|
11149
|
+
});
|
|
11150
|
+
input.addEventListener('click', () => {
|
|
11151
|
+
if (!folderListOpen()) renderFolderList();
|
|
11152
|
+
});
|
|
11153
|
+
input.addEventListener('blur', () =>
|
|
11154
|
+
setTimeout(() => {
|
|
11155
|
+
if (!list.contains(document.activeElement)) closeFolderList();
|
|
11156
|
+
}, 0),
|
|
11157
|
+
);
|
|
11158
|
+
// mousedown, not click: the input's blur would close the list before a click lands.
|
|
11159
|
+
list.addEventListener('mousedown', (e) => {
|
|
11160
|
+
e.preventDefault();
|
|
11161
|
+
if (e.target.closest('[data-browse]')) return browseNewSessionFolder();
|
|
11162
|
+
const row = e.target.closest('.ns-option');
|
|
11163
|
+
if (row) pickFolderOption(Number(row.dataset.idx));
|
|
11164
|
+
});
|
|
11165
|
+
document.getElementById('ns-wt').addEventListener('change', (e) => {
|
|
11166
|
+
renderNewSessionForm();
|
|
11167
|
+
if (e.target.checked) document.getElementById('ns-wt-name').focus();
|
|
11168
|
+
});
|
|
11169
|
+
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
|
|
11170
|
+
document.getElementById(id).addEventListener('input', renderNewSessionForm);
|
|
11171
|
+
}
|
|
11172
|
+
if (SpeechRecognitionApi) {
|
|
11173
|
+
const mic = document.getElementById('ns-mic');
|
|
11174
|
+
mic.hidden = false;
|
|
11175
|
+
// mousedown would blur the textarea and lose the caret the dictation inserts at.
|
|
11176
|
+
mic.addEventListener('mousedown', (e) => e.preventDefault());
|
|
11177
|
+
mic.addEventListener('click', togglePromptVoice);
|
|
11178
|
+
}
|
|
11179
|
+
// Bound on the dialog: the global handler returns early on INPUT and TEXTAREA targets.
|
|
11180
|
+
modal.addEventListener('keydown', (e) => {
|
|
11181
|
+
const inFolder = e.target === input;
|
|
11182
|
+
if (e.key === 'Escape') {
|
|
11183
|
+
if (folderListOpen()) closeFolderList();
|
|
11184
|
+
else closeNewSession();
|
|
11185
|
+
} else if (inFolder && matchKey(e, 'ArrowDown')) {
|
|
11186
|
+
if (!folderListOpen()) renderFolderList();
|
|
11187
|
+
else moveFolderHighlight(1);
|
|
11188
|
+
} else if (inFolder && matchKey(e, 'ArrowUp') && folderListOpen()) {
|
|
11189
|
+
moveFolderHighlight(-1);
|
|
11190
|
+
} else if (inFolder && e.key === 'Enter' && folderListOpen() && ns.idx >= 0 && ns.matches[ns.idx] !== ns.folder) {
|
|
11191
|
+
pickFolderOption(ns.idx);
|
|
11192
|
+
} else if (e.key === 'Enter' && (e.target.tagName !== 'TEXTAREA' || e.ctrlKey || e.metaKey)) {
|
|
11193
|
+
if (e.target.tagName === 'BUTTON') return;
|
|
11194
|
+
startNewSession();
|
|
11195
|
+
} else return;
|
|
11196
|
+
e.preventDefault();
|
|
11197
|
+
e.stopPropagation();
|
|
11198
|
+
});
|
|
11199
|
+
}
|
|
11200
|
+
|
|
11201
|
+
initNewSession();
|
|
11202
|
+
//#endregion
|
|
11203
|
+
|
|
9186
11204
|
//#region PWA
|
|
9187
11205
|
if ('serviceWorker' in navigator) {
|
|
9188
11206
|
navigator.serviceWorker.register('/sw.js');
|
|
@@ -9391,6 +11409,11 @@ if (urlState.search) {
|
|
|
9391
11409
|
document.getElementById('search-clear-btn').classList.add('visible');
|
|
9392
11410
|
}
|
|
9393
11411
|
|
|
11412
|
+
{
|
|
11413
|
+
const verbs = ['Clawding…', 'Shuffling cards…', 'Herding tasks…', 'Waking sessions…', 'Sorting the board…'];
|
|
11414
|
+
document.getElementById('boot-verb').textContent = pickRandom(verbs);
|
|
11415
|
+
}
|
|
11416
|
+
|
|
9394
11417
|
Promise.all([
|
|
9395
11418
|
fetch('/hub-config')
|
|
9396
11419
|
.then((r) => r.json())
|
|
@@ -9406,6 +11429,7 @@ Promise.all([
|
|
|
9406
11429
|
})
|
|
9407
11430
|
.catch(() => {}),
|
|
9408
11431
|
])
|
|
11432
|
+
.then(restorePendingSessions)
|
|
9409
11433
|
.then(() => fetchSessions())
|
|
9410
11434
|
.then(async () => {
|
|
9411
11435
|
if (urlState.projectView) {
|
|
@@ -9439,6 +11463,10 @@ Promise.all([
|
|
|
9439
11463
|
if (currentMessages.length) renderMessages(currentMessages);
|
|
9440
11464
|
});
|
|
9441
11465
|
}
|
|
11466
|
+
})
|
|
11467
|
+
.finally(() => {
|
|
11468
|
+
document.body.classList.remove('booting');
|
|
11469
|
+
sessionsList.removeAttribute('aria-busy');
|
|
9442
11470
|
});
|
|
9443
11471
|
|
|
9444
11472
|
window.addEventListener('popstate', () => {
|
|
@@ -9465,23 +11493,31 @@ window.addEventListener('popstate', () => {
|
|
|
9465
11493
|
//#endregion
|
|
9466
11494
|
|
|
9467
11495
|
// #region HUB_INTEGRATION
|
|
9468
|
-
|
|
9469
|
-
|
|
9470
|
-
|
|
9471
|
-
|
|
9472
|
-
|
|
9473
|
-
|
|
9474
|
-
if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
9475
|
-
fwd(e.key);
|
|
9476
|
-
}
|
|
11496
|
+
// e.code travels with e.key because macOS composes Option+<key> into a character (Option+P is
|
|
11497
|
+
// 'π'), so the key alone cannot identify the binding. The hub owns the keymap and normalizes;
|
|
11498
|
+
// these tests only decide whether a press is the hub's to handle.
|
|
11499
|
+
function isHubKey(e) {
|
|
11500
|
+
if (!window.__HUB__?.enabled) return false;
|
|
11501
|
+
if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) return true;
|
|
9477
11502
|
// Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
|
|
9478
11503
|
// keymap and ignores unbound letters.
|
|
9479
|
-
if (
|
|
9480
|
-
|
|
9481
|
-
|
|
9482
|
-
|
|
9483
|
-
|
|
11504
|
+
if (
|
|
11505
|
+
e.ctrlKey &&
|
|
11506
|
+
e.altKey &&
|
|
11507
|
+
!e.shiftKey &&
|
|
11508
|
+
!e.metaKey &&
|
|
11509
|
+
!CCK_CTRL_ALT_KEYS.has(e.code) &&
|
|
11510
|
+
(/^[a-z]$/i.test(e.key) || /^Key[A-Z]$/.test(e.code))
|
|
11511
|
+
) {
|
|
11512
|
+
return true;
|
|
9484
11513
|
}
|
|
11514
|
+
return e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && (/^[1-9]$/.test(e.key) || /^Digit[1-9]$/.test(e.code));
|
|
11515
|
+
}
|
|
11516
|
+
|
|
11517
|
+
document.addEventListener('keydown', (e) => {
|
|
11518
|
+
if (!isHubKey(e)) return;
|
|
11519
|
+
e.preventDefault();
|
|
11520
|
+
hubPost({ type: 'hub:keydown', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
|
|
9485
11521
|
});
|
|
9486
11522
|
|
|
9487
11523
|
document.addEventListener('click', (e) => {
|