claude-code-kanban 4.31.1 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +16 -5
- package/hub-app.json +25 -0
- package/lib/net-guard.js +6 -1
- package/lib/parsers.js +21 -12
- package/lib/terminal.js +73 -8
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +12 -0
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +10 -3
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +13 -2
- package/public/app.js +751 -387
- package/public/index.html +31 -4
- package/public/style.css +202 -15
- package/public/vendor/claude-hub-sdk.js +50 -0
- package/server.js +90 -29
package/public/app.js
CHANGED
|
@@ -74,6 +74,8 @@ let agentPollInterval = null;
|
|
|
74
74
|
// Inside the hub an inactive app is a display:none iframe whose document.hidden stays false,
|
|
75
75
|
// so the hub tells us via hub:active. Standalone, or if that message is lost, cck stays active.
|
|
76
76
|
let hubActive = true;
|
|
77
|
+
// setupEventSource runs before HUB_INTEGRATION connects, so it hands its handler over here.
|
|
78
|
+
let onHubActive = () => {};
|
|
77
79
|
let missedWhileHidden = false;
|
|
78
80
|
const isOnScreen = () => hubActive && !document.hidden;
|
|
79
81
|
function skipOffScreen() {
|
|
@@ -182,6 +184,7 @@ function updateUrl() {
|
|
|
182
184
|
history.replaceState(null, '', url);
|
|
183
185
|
persistLastView();
|
|
184
186
|
syncTerminal();
|
|
187
|
+
schedulePublishSession();
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
const LAST_VIEW_KEY = 'lastView';
|
|
@@ -286,8 +289,10 @@ async function fetchSessions(includeTasks = true) {
|
|
|
286
289
|
if (sessionsHash !== lastSessionsHash) {
|
|
287
290
|
lastSessionsHash = sessionsHash;
|
|
288
291
|
sessions = mergePlaceholders(newSessions);
|
|
292
|
+
markOpenSessionRead();
|
|
289
293
|
renderSessions();
|
|
290
294
|
renderActivityChip();
|
|
295
|
+
schedulePublishSession();
|
|
291
296
|
}
|
|
292
297
|
// A list too short to scroll gives no scroll event, so fill it until it can scroll.
|
|
293
298
|
if (sessionsHasMore) setTimeout(loadMoreIfNearEnd);
|
|
@@ -298,6 +303,21 @@ async function fetchSessions(includeTasks = true) {
|
|
|
298
303
|
}
|
|
299
304
|
}
|
|
300
305
|
|
|
306
|
+
// The Stop hook leaves _stop.json when a session ends its turn; deleting it marks the session read.
|
|
307
|
+
function markSessionRead(sessionId) {
|
|
308
|
+
const session = sessions.find((s) => s.id === sessionId);
|
|
309
|
+
if (!session?.unread) return;
|
|
310
|
+
session.unread = false;
|
|
311
|
+
document.querySelector(`.session-item[data-session-id="${CSS.escape(sessionId)}"]`)?.classList.remove('unread');
|
|
312
|
+
fetch(`/api/sessions/${encodeURIComponent(sessionId)}/read`, { method: 'POST' }).catch((e) =>
|
|
313
|
+
console.error('[markSessionRead]', e),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function markOpenSessionRead() {
|
|
318
|
+
if (currentSessionId && viewMode === 'session' && isOnScreen()) markSessionRead(currentSessionId);
|
|
319
|
+
}
|
|
320
|
+
|
|
301
321
|
async function loadSearchTasks() {
|
|
302
322
|
const newTasks = await fetch('/api/tasks/all').then((r) => r.json());
|
|
303
323
|
const tasksHash = JSON.stringify(newTasks);
|
|
@@ -426,29 +446,55 @@ function isActiveSession(s) {
|
|
|
426
446
|
function isSessionLive(s) {
|
|
427
447
|
return !!s.hasRecentLog && Date.now() - Date.parse(s.modifiedAt) <= LIVE_INDICATOR_MS;
|
|
428
448
|
}
|
|
449
|
+
function isJustFinished(s) {
|
|
450
|
+
return (
|
|
451
|
+
!isWaitingSession(s) &&
|
|
452
|
+
!isActiveSession(s) &&
|
|
453
|
+
!!s.hasRecentActivity &&
|
|
454
|
+
Date.now() - Date.parse(s.modifiedAt) < JUST_NOW_MS
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// The JUST_NOW_MS mark is a client-side boundary; no server update arrives when a session crosses it.
|
|
459
|
+
let justFinishedTimer = null;
|
|
460
|
+
function scheduleJustFinishedExpiry() {
|
|
461
|
+
clearTimeout(justFinishedTimer);
|
|
462
|
+
let next = Infinity;
|
|
463
|
+
for (const s of sessions) {
|
|
464
|
+
if (isJustFinished(s)) next = Math.min(next, Date.parse(s.modifiedAt) + JUST_NOW_MS - Date.now());
|
|
465
|
+
}
|
|
466
|
+
if (next === Infinity) return;
|
|
467
|
+
justFinishedTimer = setTimeout(() => {
|
|
468
|
+
if (!skipOffScreen()) renderSessionViews();
|
|
469
|
+
}, next + 50);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function renderSessionViews() {
|
|
473
|
+
renderSessions();
|
|
474
|
+
if (isSessionPickerOpen()) renderSessionPicker();
|
|
475
|
+
}
|
|
429
476
|
|
|
430
477
|
const ACTIVITY_PREDICATES = {
|
|
431
478
|
waiting: isWaitingSession,
|
|
432
479
|
active: isActiveSession,
|
|
480
|
+
terminal: (s) => runningTerminals.has(s.id),
|
|
433
481
|
};
|
|
434
482
|
|
|
435
|
-
let
|
|
483
|
+
let lastChipHtml = '';
|
|
436
484
|
|
|
437
485
|
function renderActivityChip() {
|
|
438
486
|
const container = document.getElementById('activity-chips');
|
|
439
487
|
if (!container) return;
|
|
440
488
|
|
|
441
|
-
|
|
442
|
-
let active = 0;
|
|
489
|
+
const counts = { waiting: 0, active: 0, terminal: 0 };
|
|
443
490
|
for (const s of sessions) {
|
|
444
491
|
if (dismissedSessionIds.has(s.id)) continue;
|
|
445
|
-
if (s
|
|
446
|
-
else if (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents) active++;
|
|
492
|
+
for (const kind in counts) if (ACTIVITY_PREDICATES[kind](s)) counts[kind]++;
|
|
447
493
|
}
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
494
|
+
const { waiting, active, terminal: terminals } = counts;
|
|
495
|
+
const showTerminals = terminalAvailable();
|
|
496
|
+
const maxTerminals = appConfig.terminal?.maxSessions;
|
|
497
|
+
const nearCap = showTerminals && runningTerminals.size >= maxTerminals * 0.8;
|
|
452
498
|
|
|
453
499
|
const chips = [
|
|
454
500
|
{
|
|
@@ -464,14 +510,27 @@ function renderActivityChip() {
|
|
|
464
510
|
title: `${active} session${active === 1 ? '' : 's'} with running work or recent activity`,
|
|
465
511
|
},
|
|
466
512
|
];
|
|
513
|
+
if (showTerminals) {
|
|
514
|
+
chips.push({
|
|
515
|
+
kind: 'terminal',
|
|
516
|
+
count: terminals,
|
|
517
|
+
label: nearCap ? `${runningTerminals.size}/${maxTerminals}` : `${terminals}`,
|
|
518
|
+
title: nearCap
|
|
519
|
+
? `${runningTerminals.size} of ${maxTerminals} terminals open; end some to start more`
|
|
520
|
+
: `${terminals} session${terminals === 1 ? '' : 's'} running in a terminal here`,
|
|
521
|
+
icon: ICON_TERMINAL,
|
|
522
|
+
warn: nearCap,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
467
525
|
|
|
468
|
-
|
|
526
|
+
const html = chips
|
|
469
527
|
.map((c) => {
|
|
470
528
|
const isOn = activityFilter.has(c.kind);
|
|
471
529
|
const classes = [
|
|
472
530
|
'activity-chip',
|
|
473
531
|
`activity-${c.kind}`,
|
|
474
|
-
c.count === 0 ? 'activity-zero' : '',
|
|
532
|
+
c.count === 0 && !c.warn ? 'activity-zero' : '',
|
|
533
|
+
c.warn ? 'activity-warn' : '',
|
|
475
534
|
isOn ? 'activity-filter-on' : '',
|
|
476
535
|
]
|
|
477
536
|
.filter(Boolean)
|
|
@@ -483,12 +542,15 @@ function renderActivityChip() {
|
|
|
483
542
|
onclick="setActivityFilter('${escAttrJs(c.kind)}')"
|
|
484
543
|
aria-pressed="${isOn ? 'true' : 'false'}"
|
|
485
544
|
title="${escapeHtml(c.title + hint)}">
|
|
486
|
-
<span class="activity-dot"></span>
|
|
545
|
+
${c.icon ? `<span class="activity-icon">${c.icon}</span>` : '<span class="activity-dot"></span>'}
|
|
487
546
|
<span class="activity-label">${escapeHtml(c.label)}</span>
|
|
488
547
|
</button>
|
|
489
548
|
`;
|
|
490
549
|
})
|
|
491
550
|
.join('');
|
|
551
|
+
if (html === lastChipHtml) return;
|
|
552
|
+
lastChipHtml = html;
|
|
553
|
+
container.innerHTML = html;
|
|
492
554
|
}
|
|
493
555
|
|
|
494
556
|
function toggleActivityKind(kind) {
|
|
@@ -524,6 +586,7 @@ let lastCurrentTasksHash = '';
|
|
|
524
586
|
|
|
525
587
|
async function fetchTasks(sessionId) {
|
|
526
588
|
try {
|
|
589
|
+
const refresh = viewMode === 'session' && sessionId === currentSessionId;
|
|
527
590
|
viewMode = 'session';
|
|
528
591
|
document.getElementById('message-toggle')?.style.removeProperty('display');
|
|
529
592
|
const res = await fetch(`/api/sessions/${sessionId}`);
|
|
@@ -532,9 +595,7 @@ async function fetchTasks(sessionId) {
|
|
|
532
595
|
const newTasks = await res.json();
|
|
533
596
|
|
|
534
597
|
const hash = JSON.stringify(newTasks);
|
|
535
|
-
if (sessionId === currentSessionId && hash === lastCurrentTasksHash)
|
|
536
|
-
return;
|
|
537
|
-
}
|
|
598
|
+
if (sessionId === currentSessionId && hash === lastCurrentTasksHash) return;
|
|
538
599
|
lastCurrentTasksHash = hash;
|
|
539
600
|
|
|
540
601
|
currentTasks = newTasks;
|
|
@@ -548,18 +609,24 @@ async function fetchTasks(sessionId) {
|
|
|
548
609
|
if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
|
|
549
610
|
revealedStorageSessionId = null;
|
|
550
611
|
}
|
|
551
|
-
if (currentSessionId && currentSessionId !== sessionId
|
|
612
|
+
if (currentSessionId && currentSessionId !== sessionId && deferredPinPlacement.delete(currentSessionId))
|
|
613
|
+
expandPinnedFor(currentSessionId);
|
|
552
614
|
if (lastSessionId !== sessionId) setSwapPair(sessionId, lastSessionId);
|
|
553
615
|
const switched = sessionId !== currentSessionId;
|
|
554
616
|
currentSessionId = sessionId;
|
|
617
|
+
markOpenSessionRead();
|
|
555
618
|
if (switched) autoRevealLog(sessionId, newTasks.length === 0);
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
619
|
+
// A task change in the open session must not reset the messages, the agents or the owner
|
|
620
|
+
// filter: that reset rebuilds the panels and reads as a reload after each card move.
|
|
621
|
+
if (!refresh) {
|
|
622
|
+
currentPins = loadPins(sessionId);
|
|
623
|
+
ownerFilter = '';
|
|
624
|
+
resetMessageScrollState();
|
|
625
|
+
for (const k of Object.keys(ownerColorCache)) delete ownerColorCache[k];
|
|
626
|
+
for (const k of Object.keys(teamColorMap)) delete teamColorMap[k];
|
|
627
|
+
sessionJustSelected = true;
|
|
628
|
+
resetAgentState();
|
|
629
|
+
}
|
|
563
630
|
updateUrl();
|
|
564
631
|
renderSession();
|
|
565
632
|
renderSessions();
|
|
@@ -579,6 +646,7 @@ async function fetchTasks(sessionId) {
|
|
|
579
646
|
const WAITING_TTL_MS = 30 * 60 * 1000;
|
|
580
647
|
const AGENT_LOG_MAX = 8;
|
|
581
648
|
const LIVE_INDICATOR_MS = 10 * 1000;
|
|
649
|
+
const JUST_NOW_MS = 60 * 1000;
|
|
582
650
|
// #endregion
|
|
583
651
|
|
|
584
652
|
function resetAgentState() {
|
|
@@ -1658,6 +1726,7 @@ const ICON_AGENT_ACTIVE =
|
|
|
1658
1726
|
'<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>';
|
|
1659
1727
|
const ICON_TERMINAL =
|
|
1660
1728
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>';
|
|
1729
|
+
const TERMINAL_TITLE = 'Running in a terminal here';
|
|
1661
1730
|
const ICON_CHAT =
|
|
1662
1731
|
'<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>';
|
|
1663
1732
|
const TOOL_ICONS = {
|
|
@@ -1913,6 +1982,7 @@ function toggleSessionPin(sessionId) {
|
|
|
1913
1982
|
} else {
|
|
1914
1983
|
pinnedSessionIds.add(sessionId);
|
|
1915
1984
|
if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
|
|
1985
|
+
expandPinnedFor(sessionId);
|
|
1916
1986
|
}
|
|
1917
1987
|
savePinnedSessions();
|
|
1918
1988
|
offloadSessionPin(sessionId);
|
|
@@ -1951,6 +2021,7 @@ function handleSessionPinEvent({ id, state }) {
|
|
|
1951
2021
|
pinnedSessionIds.add(id);
|
|
1952
2022
|
stickySessionIds.add(id);
|
|
1953
2023
|
}
|
|
2024
|
+
expandPinnedFor(id);
|
|
1954
2025
|
savePinnedSessions();
|
|
1955
2026
|
renderSessions();
|
|
1956
2027
|
}
|
|
@@ -2411,6 +2482,10 @@ function showToast(msg, type, action) {
|
|
|
2411
2482
|
_toastTimer = setTimeout(hide, action ? 8000 : 2000);
|
|
2412
2483
|
}
|
|
2413
2484
|
|
|
2485
|
+
function sendMessageAddress(peerName) {
|
|
2486
|
+
return `SendMessage(${peerName})`;
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2414
2489
|
async function copyWithFeedback(text, btn) {
|
|
2415
2490
|
if (btn.dataset.copying) return;
|
|
2416
2491
|
try {
|
|
@@ -3296,7 +3371,7 @@ function getFilteredSessions() {
|
|
|
3296
3371
|
// Skip pinned sessions — they are prepended separately below (lines ~2180) to preserve stable position.
|
|
3297
3372
|
const filteredIds = new Set(filteredSessions.map((s) => s.id));
|
|
3298
3373
|
for (const id of [revealedPlanSessionId, revealedStorageSessionId, currentSessionId]) {
|
|
3299
|
-
if (id && !filteredIds.has(id) && !isAnyPinned(id)) {
|
|
3374
|
+
if (id && !filteredIds.has(id) && !(isAnyPinned(id) && activityFilter.size === 0)) {
|
|
3300
3375
|
const session = sessions.find((s) => s.id === id);
|
|
3301
3376
|
if (session) {
|
|
3302
3377
|
const insertAt = filteredSessions.findIndex((s) => s.modifiedAt < session.modifiedAt);
|
|
@@ -3310,12 +3385,19 @@ function getFilteredSessions() {
|
|
|
3310
3385
|
// The open session keeps its row even when it belongs to another project, so that
|
|
3311
3386
|
// revealing one by id has something to scroll to. Same exemption the active branch
|
|
3312
3387
|
// above already makes, for the same reason.
|
|
3313
|
-
filteredSessions = filteredSessions.filter(
|
|
3388
|
+
filteredSessions = filteredSessions.filter(
|
|
3389
|
+
(s) =>
|
|
3390
|
+
matchesProjectFilter(s.project) ||
|
|
3391
|
+
s.id === currentSessionId ||
|
|
3392
|
+
(filterProject === '__recent__' && isAnyPinned(s.id)),
|
|
3393
|
+
);
|
|
3314
3394
|
}
|
|
3315
3395
|
|
|
3316
3396
|
if (activityFilter.size > 0) {
|
|
3317
3397
|
const preds = [...activityFilter].map((k) => ACTIVITY_PREDICATES[k]).filter(Boolean);
|
|
3318
|
-
if (preds.length)
|
|
3398
|
+
if (preds.length) {
|
|
3399
|
+
filteredSessions = filteredSessions.filter((s) => s.id === currentSessionId || preds.some((p) => p(s)));
|
|
3400
|
+
}
|
|
3319
3401
|
}
|
|
3320
3402
|
|
|
3321
3403
|
if (searchQuery) {
|
|
@@ -3377,6 +3459,7 @@ function renderSessions() {
|
|
|
3377
3459
|
// Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
|
|
3378
3460
|
// half-typed group name, and the SSE path can fire at any moment — defer instead.
|
|
3379
3461
|
if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
|
|
3462
|
+
scheduleJustFinishedExpiry();
|
|
3380
3463
|
refreshProjectList();
|
|
3381
3464
|
|
|
3382
3465
|
// Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
|
|
@@ -3445,10 +3528,19 @@ function renderSessions() {
|
|
|
3445
3528
|
const linkedDocsCount = getSessionPreviewPaths(session.id).length;
|
|
3446
3529
|
const bookmarksCount = loadPins(session.id).length;
|
|
3447
3530
|
const hasScratchpad = _hasScratchpad(_sessionScratchpadKey(session.id));
|
|
3448
|
-
const tempClass =
|
|
3531
|
+
const tempClass =
|
|
3532
|
+
session.hasRecentLog || session.hasRecentActivity || session.inProgress || session.hasWaitingForUser
|
|
3533
|
+
? 'warm'
|
|
3534
|
+
: 'stale';
|
|
3535
|
+
// Open tasks keep an idle session warm, but the green dot is for a session that is working.
|
|
3536
|
+
const idleClass =
|
|
3537
|
+
tempClass === 'warm' && !session.hasWaitingForUser && !session.hasRecentLog && !session.hasRunningAgents
|
|
3538
|
+
? 'idle'
|
|
3539
|
+
: '';
|
|
3540
|
+
const justFinishedClass = isJustFinished(session) ? 'just-finished' : '';
|
|
3449
3541
|
const sid = escAttrJs(session.id);
|
|
3450
3542
|
return `
|
|
3451
|
-
<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)}">
|
|
3543
|
+
<button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${session.unread ? 'unread' : ''} ${tempClass} ${idleClass} ${justFinishedClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
|
|
3452
3544
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
3453
3545
|
<div class="session-name">${escapeHtml(sessionName)}</div>
|
|
3454
3546
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
@@ -3466,7 +3558,7 @@ function renderSessions() {
|
|
|
3466
3558
|
${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
|
|
3467
3559
|
${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escAttrJs(session.planSourceSessionId)}')">${ICON_PLAN}</span>` : ''}
|
|
3468
3560
|
${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
|
|
3469
|
-
${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="
|
|
3561
|
+
${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>` : ''}
|
|
3470
3562
|
${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
|
|
3471
3563
|
${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
|
|
3472
3564
|
${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
|
|
@@ -3506,12 +3598,13 @@ function renderSessions() {
|
|
|
3506
3598
|
return `<span class="group-count" title="${active} active / ${arr.length} total">${active > 0 ? `<span class="group-count-active">${active}</span><span class="group-count-sep">/</span>` : ''}${arr.length}</span>`;
|
|
3507
3599
|
};
|
|
3508
3600
|
// `ungroup` marks the label as the drop target for pulling an item out of a named group.
|
|
3509
|
-
const sectionHtml = (key, text, ungroup, body) => {
|
|
3601
|
+
const sectionHtml = (key, text, ungroup, body, empty = false) => {
|
|
3510
3602
|
const collapsed = collapsedProjectGroups.has(key);
|
|
3511
3603
|
const escPath = escapeHtml(key);
|
|
3512
3604
|
const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
|
|
3513
|
-
|
|
3514
|
-
|
|
3605
|
+
const emptyCls = empty ? ' sg-section-empty' : '';
|
|
3606
|
+
return `<div class="sg-section-label sg-section-toggle${ungroup ? ' sg-ungroup-zone' : ''}${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}" title="${escapeHtml(title)}">${groupChevronSvg(10)}<span>${text}</span></div>
|
|
3607
|
+
<div class="sg-section-body${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}">${body}</div>`;
|
|
3515
3608
|
};
|
|
3516
3609
|
|
|
3517
3610
|
const renderGroupSessions = (sessions, pinKey) => {
|
|
@@ -3521,7 +3614,7 @@ function renderSessions() {
|
|
|
3521
3614
|
const gIdlePinned = gPinned.filter((s) => !isSessionActive(s));
|
|
3522
3615
|
const gUnpinned = sessions.filter((s) => !isPlacedPinned(s.id) || isSessionActive(s) || isPlacedSticky(s.id));
|
|
3523
3616
|
const pinCollapsed = collapsedProjectGroups.has(pinKey);
|
|
3524
|
-
if (gIdlePinned.length === 0
|
|
3617
|
+
if (gIdlePinned.length === 0) return gUnpinned.map(renderSessionCard).join('');
|
|
3525
3618
|
return (
|
|
3526
3619
|
'<div class="pinned-sub-section">' +
|
|
3527
3620
|
'<div class="pinned-sub-header' +
|
|
@@ -3732,14 +3825,15 @@ function renderSessions() {
|
|
|
3732
3825
|
}
|
|
3733
3826
|
}
|
|
3734
3827
|
|
|
3735
|
-
|
|
3828
|
+
const noProjects = sortedGroups.length === 0 && ungrouped.length === 0;
|
|
3829
|
+
html += sectioned ? sectionHtml(SECTION_PROJECTS, 'Projects', true, projectsHtml, noProjects) : projectsHtml;
|
|
3736
3830
|
|
|
3737
3831
|
sessionsList.innerHTML = html;
|
|
3738
3832
|
} else {
|
|
3739
3833
|
const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
|
|
3740
3834
|
const restHtml =
|
|
3741
3835
|
sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
|
|
3742
|
-
const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml) : restHtml;
|
|
3836
|
+
const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
|
|
3743
3837
|
sessionsList.innerHTML = html + sgSectionHtml(true) + tail;
|
|
3744
3838
|
}
|
|
3745
3839
|
|
|
@@ -3788,8 +3882,7 @@ function renderSession() {
|
|
|
3788
3882
|
// happened to open the info modal or zen panel.
|
|
3789
3883
|
loadSessionPads(currentSessionId);
|
|
3790
3884
|
|
|
3791
|
-
const displayName =
|
|
3792
|
-
session.customTitle || session.name || session.gitBranch || session.description || currentSessionId;
|
|
3885
|
+
const displayName = session.name || session.description || currentSessionId;
|
|
3793
3886
|
|
|
3794
3887
|
sessionTitle.textContent = displayName;
|
|
3795
3888
|
|
|
@@ -3842,8 +3935,17 @@ function renderProjectView() {
|
|
|
3842
3935
|
renderSessions();
|
|
3843
3936
|
}
|
|
3844
3937
|
|
|
3938
|
+
// Claude Code keeps a blocker's id in blockedBy after the blocker completes.
|
|
3939
|
+
function openBlockers(task) {
|
|
3940
|
+
const list = (t) => t.sessionId || t._taskDir;
|
|
3941
|
+
return (task.blockedBy || []).filter(
|
|
3942
|
+
(id) => currentTasks.find((t) => t.id === id && list(t) === list(task))?.status !== 'completed',
|
|
3943
|
+
);
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3845
3946
|
function renderTaskCard(task) {
|
|
3846
|
-
const
|
|
3947
|
+
const blockers = openBlockers(task);
|
|
3948
|
+
const isBlocked = blockers.length > 0;
|
|
3847
3949
|
const useSlug = viewMode === 'project';
|
|
3848
3950
|
const taskId = useSlug ? `${(task._taskDir || task.sessionId || '')?.slice(0, 4)}-${task.id}` : task.id;
|
|
3849
3951
|
const statusClass = task.status.replace('_', '-');
|
|
@@ -3877,7 +3979,7 @@ function renderTaskCard(task) {
|
|
|
3877
3979
|
</div>
|
|
3878
3980
|
<div class="task-title">${escapeHtml(task.subject)}</div>
|
|
3879
3981
|
${task.status === 'in_progress' && task.activeForm ? `<div class="task-active">${escapeHtml(task.activeForm)}</div>` : ''}
|
|
3880
|
-
${isBlocked ? `<div class="task-blocked">Waiting on ${
|
|
3982
|
+
${isBlocked ? `<div class="task-blocked">Waiting on ${blockers.map((id) => `#${id}`).join(', ')}</div>` : ''}
|
|
3881
3983
|
${task.description ? `<div class="task-desc">${escapeHtml(task.description.split('\n')[0])}</div>` : ''}
|
|
3882
3984
|
</div>
|
|
3883
3985
|
`;
|
|
@@ -3960,7 +4062,7 @@ function renderKanban() {
|
|
|
3960
4062
|
// away mid-typing -- leave the column alone until the input is gone.
|
|
3961
4063
|
if (!addingTask) {
|
|
3962
4064
|
const addTile = canAddTask()
|
|
3963
|
-
? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this)">${plusIcon}<span>Add task</span></button>`
|
|
4065
|
+
? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this)" title="You can also ask the agent to add tasks with its TaskCreate tool. Make sure CLAUDE_CODE_ENABLE_TASKS is set in its environment.">${plusIcon}<span>Add task</span></button>`
|
|
3964
4066
|
: '';
|
|
3965
4067
|
writes.push([
|
|
3966
4068
|
pendingTasks,
|
|
@@ -4143,17 +4245,21 @@ async function onColumnDrop(e) {
|
|
|
4143
4245
|
(t) => t.id === taskId && (t._taskDir === sessionId || (t.sessionId || currentSessionId) === sessionId),
|
|
4144
4246
|
);
|
|
4145
4247
|
if (!task || task.status === newStatus) return;
|
|
4248
|
+
const oldStatus = task.status;
|
|
4249
|
+
task.status = newStatus;
|
|
4250
|
+
renderKanban();
|
|
4146
4251
|
try {
|
|
4147
4252
|
const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
|
|
4148
4253
|
method: 'PUT',
|
|
4149
4254
|
headers: { 'Content-Type': 'application/json' },
|
|
4150
4255
|
body: JSON.stringify({ status: newStatus }),
|
|
4151
4256
|
});
|
|
4152
|
-
if (res.ok)
|
|
4153
|
-
task.status = newStatus;
|
|
4154
|
-
renderKanban();
|
|
4155
|
-
}
|
|
4257
|
+
if (res.ok) return;
|
|
4156
4258
|
} catch (_) {}
|
|
4259
|
+
if (task.status === newStatus) {
|
|
4260
|
+
task.status = oldStatus;
|
|
4261
|
+
renderKanban();
|
|
4262
|
+
}
|
|
4157
4263
|
}
|
|
4158
4264
|
|
|
4159
4265
|
//#endregion
|
|
@@ -4384,9 +4490,9 @@ function sgHeaderHtml(group, countHtml) {
|
|
|
4384
4490
|
const actions = group.transient
|
|
4385
4491
|
? `<span class="sg-action sg-keep" title="Keep this group after its sessions end">Keep</span>
|
|
4386
4492
|
<span class="sg-action sg-delete" title="Delete group (sessions return to Projects)">×</span>`
|
|
4387
|
-
:
|
|
4388
|
-
<span class="sg-action sg-
|
|
4389
|
-
|
|
4493
|
+
: `<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>
|
|
4494
|
+
<span class="sg-action sg-delete" title="Delete group (members return to Projects)">×</span>
|
|
4495
|
+
${headerPadBtnHtml(_groupScratchpadKey(group.id), group.name)}`;
|
|
4390
4496
|
return `
|
|
4391
4497
|
<div ${attrs} data-group-path="${escapeHtml(sgKey(group.id))}">
|
|
4392
4498
|
${groupChevronSvg()}
|
|
@@ -4899,6 +5005,7 @@ function getNavigableItems() {
|
|
|
4899
5005
|
// into the body to reach the blocks inside.
|
|
4900
5006
|
const walkTopLevel = (children) => {
|
|
4901
5007
|
for (const el of children) {
|
|
5008
|
+
if (el.classList.contains('sg-section-empty')) continue;
|
|
4902
5009
|
if (
|
|
4903
5010
|
el.classList.contains('project-group-header') ||
|
|
4904
5011
|
el.classList.contains('session-group-header') ||
|
|
@@ -5007,14 +5114,11 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
5007
5114
|
// it (pinned, not sticky, idle — see renderGroupSessions), and — since which section holds it
|
|
5008
5115
|
// depends on the view — every section header. Returns whether anything changed.
|
|
5009
5116
|
function uncollapseFor(session) {
|
|
5010
|
-
const project = session.project || '__ungrouped__';
|
|
5011
5117
|
const group = sgGroupForSession(session);
|
|
5012
|
-
const inPinned = isPlacedPinned(session.id) && !isPlacedSticky(session.id) && !isSessionActive(session);
|
|
5013
5118
|
const keys = [
|
|
5014
|
-
project,
|
|
5015
|
-
|
|
5119
|
+
session.project || '__ungrouped__',
|
|
5120
|
+
...pinnedCollapseKeys(session),
|
|
5016
5121
|
group && sgKey(group.id),
|
|
5017
|
-
group && inPinned && `__pinned_group_${group.id}__`,
|
|
5018
5122
|
SECTION_GROUPS,
|
|
5019
5123
|
SECTION_PROJECTS,
|
|
5020
5124
|
SECTION_SESSIONS,
|
|
@@ -5026,6 +5130,24 @@ function uncollapseFor(session) {
|
|
|
5026
5130
|
return changed;
|
|
5027
5131
|
}
|
|
5028
5132
|
|
|
5133
|
+
function pinnedCollapseKeys(session) {
|
|
5134
|
+
if (!isPlacedPinned(session.id) || isPlacedSticky(session.id) || isSessionActive(session)) return [];
|
|
5135
|
+
const group = sgGroupForSession(session);
|
|
5136
|
+
const host = sgHostOf(group, session);
|
|
5137
|
+
return [pinKey(session.project || '__ungrouped__'), host && pinKey(host), group && `__pinned_group_${group.id}__`];
|
|
5138
|
+
}
|
|
5139
|
+
|
|
5140
|
+
// A card that moves into a collapsed Pinned sub-section would vanish from under the click.
|
|
5141
|
+
function expandPinnedFor(sessionId) {
|
|
5142
|
+
const session = sessions.find((s) => s.id === sessionId);
|
|
5143
|
+
if (!session) return;
|
|
5144
|
+
let changed = false;
|
|
5145
|
+
for (const key of pinnedCollapseKeys(session)) {
|
|
5146
|
+
if (key && collapsedProjectGroups.delete(key)) changed = true;
|
|
5147
|
+
}
|
|
5148
|
+
if (changed) persistCollapsedGroups();
|
|
5149
|
+
}
|
|
5150
|
+
|
|
5029
5151
|
function isGroupHeader(el) {
|
|
5030
5152
|
return el.matches(COLLAPSIBLE_HEADER_SELECTOR);
|
|
5031
5153
|
}
|
|
@@ -5088,7 +5210,7 @@ function activateSelectedSession(items) {
|
|
|
5088
5210
|
}
|
|
5089
5211
|
}
|
|
5090
5212
|
|
|
5091
|
-
function setFocusZone(zone) {
|
|
5213
|
+
function setFocusZone(zone, target) {
|
|
5092
5214
|
clearKbSelection();
|
|
5093
5215
|
clearTaskSelection();
|
|
5094
5216
|
|
|
@@ -5097,8 +5219,11 @@ function setFocusZone(zone) {
|
|
|
5097
5219
|
expandSidebar();
|
|
5098
5220
|
const items = getNavigableItems();
|
|
5099
5221
|
if (items.length > 0) {
|
|
5222
|
+
const targetIdx = target ? items.indexOf(target) : -1;
|
|
5100
5223
|
const activeIdx = items.findIndex((el) => el.classList.contains('active'));
|
|
5101
|
-
if (
|
|
5224
|
+
if (targetIdx >= 0) {
|
|
5225
|
+
selectSessionByIndex(targetIdx, items);
|
|
5226
|
+
} else if (activeIdx >= 0) {
|
|
5102
5227
|
selectSessionByIndex(activeIdx);
|
|
5103
5228
|
} else if (selectedSessionKbId) {
|
|
5104
5229
|
const restoredIdx = items.findIndex((el) => getKbId(el) === selectedSessionKbId);
|
|
@@ -5129,6 +5254,18 @@ function setFocusZone(zone) {
|
|
|
5129
5254
|
}
|
|
5130
5255
|
}
|
|
5131
5256
|
|
|
5257
|
+
// A pointer click puts the keyboard cursor on the row, unless a task card or the terminal holds it.
|
|
5258
|
+
// Enter's el.click() arrives with detail 0 and already has the cursor.
|
|
5259
|
+
sessionsList.addEventListener('click', (e) => {
|
|
5260
|
+
if (e.detail === 0 || e.target.closest(SG_ACTION_SELECTOR)) return;
|
|
5261
|
+
const el = e.target.closest(
|
|
5262
|
+
'.session-item, .project-group-header, .session-group-header, .pinned-sub-header, .sg-section-toggle',
|
|
5263
|
+
);
|
|
5264
|
+
if (!el) return;
|
|
5265
|
+
const taskInUse = selectedTaskId && selectedSessionId === (el.dataset.sessionId || currentSessionId);
|
|
5266
|
+
if (focusZone === 'sidebar' || (!taskInUse && !terminalPaneFocused())) setFocusZone('sidebar', el);
|
|
5267
|
+
});
|
|
5268
|
+
|
|
5132
5269
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5133
5270
|
function getAvailableTasksOptions(currentTaskId = null) {
|
|
5134
5271
|
const pending = currentTasks.filter((t) => t.status === 'pending' && t.id !== currentTaskId);
|
|
@@ -5198,7 +5335,8 @@ async function showTaskDetail(taskId, sessionId = null) {
|
|
|
5198
5335
|
pending: '<span class="detail-status pending"><span class="dot"></span>Pending</span>',
|
|
5199
5336
|
};
|
|
5200
5337
|
|
|
5201
|
-
const
|
|
5338
|
+
const blockers = openBlockers(task);
|
|
5339
|
+
const isBlocked = blockers.length > 0;
|
|
5202
5340
|
const actualSessionId = task.sessionId || sessionId || currentSessionId;
|
|
5203
5341
|
|
|
5204
5342
|
detailContent.innerHTML = `
|
|
@@ -5231,12 +5369,12 @@ async function showTaskDetail(taskId, sessionId = null) {
|
|
|
5231
5369
|
}
|
|
5232
5370
|
|
|
5233
5371
|
${
|
|
5234
|
-
|
|
5372
|
+
isBlocked
|
|
5235
5373
|
? `
|
|
5236
5374
|
<div class="detail-section">
|
|
5237
5375
|
<div class="detail-label">Blocked By</div>
|
|
5238
5376
|
<div class="detail-deps">
|
|
5239
|
-
<div class="detail-box blocked"><strong>Blocked by:</strong> ${
|
|
5377
|
+
<div class="detail-box blocked"><strong>Blocked by:</strong> ${blockers.map((id) => `#${id}`).join(', ')}</div>
|
|
5240
5378
|
</div>
|
|
5241
5379
|
</div>`
|
|
5242
5380
|
: ''
|
|
@@ -5495,147 +5633,219 @@ async function confirmDelete() {
|
|
|
5495
5633
|
//#endregion
|
|
5496
5634
|
|
|
5497
5635
|
//#region HELP
|
|
5498
|
-
// Each
|
|
5499
|
-
//
|
|
5500
|
-
//
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5636
|
+
// Each tab shows its groups two to a row; a pair shares grid rows, so the two headings sit level and
|
|
5637
|
+
// the shorter group leaves empty rows. `combo` joins the keys with a plus (a chord) instead of
|
|
5638
|
+
// listing them as alternatives. The `hub` tab only works inside Claude Code Hub.
|
|
5639
|
+
const SHORTCUT_TABS = [
|
|
5640
|
+
{
|
|
5641
|
+
title: 'Board',
|
|
5642
|
+
groups: [
|
|
5643
|
+
{
|
|
5644
|
+
title: 'Navigate',
|
|
5645
|
+
rows: [
|
|
5646
|
+
{ keys: ['↓'], label: 'Next item' },
|
|
5647
|
+
{ keys: ['↑'], label: 'Previous item' },
|
|
5648
|
+
{ keys: ['←'], label: 'Left column / collapse group' },
|
|
5649
|
+
{ keys: ['→'], label: 'Right column / expand group' },
|
|
5650
|
+
{ keys: ['Tab'], label: 'Switch sidebar ↔ board' },
|
|
5651
|
+
{ keys: ['Enter', 'Space'], label: 'Open selected item' },
|
|
5652
|
+
],
|
|
5653
|
+
},
|
|
5654
|
+
{
|
|
5655
|
+
title: 'Board',
|
|
5656
|
+
rows: [
|
|
5657
|
+
{ keys: ['Enter'], label: 'Toggle task detail panel' },
|
|
5658
|
+
{ keys: ['D'], label: 'Delete selected task' },
|
|
5659
|
+
{ keys: ['N'], label: 'Toggle scratchpad (in the sidebar: the item under the cursor)' },
|
|
5660
|
+
{ keys: ['R'], label: 'Refresh data' },
|
|
5661
|
+
{ keys: ['Esc'], label: 'Close panel / clear selection' },
|
|
5662
|
+
],
|
|
5663
|
+
},
|
|
5664
|
+
{
|
|
5665
|
+
title: 'View',
|
|
5666
|
+
rows: [
|
|
5667
|
+
{ keys: ['['], label: 'Toggle sidebar' },
|
|
5668
|
+
{ keys: ['T'], label: 'Toggle theme' },
|
|
5669
|
+
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
5670
|
+
{ keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
5671
|
+
{ keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
|
|
5672
|
+
{ keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
|
|
5673
|
+
{ keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
|
|
5674
|
+
{ keys: ['?'], label: 'Show this help' },
|
|
5675
|
+
],
|
|
5676
|
+
},
|
|
5677
|
+
{
|
|
5678
|
+
title: 'Copy',
|
|
5679
|
+
rows: [
|
|
5680
|
+
{ keys: ['Shift', 'C'], combo: true, label: 'Session id' },
|
|
5681
|
+
{ keys: ['Ctrl', 'Shift', 'C'], combo: true, label: 'Session name (SendMessage address)' },
|
|
5682
|
+
],
|
|
5683
|
+
},
|
|
5684
|
+
],
|
|
5685
|
+
},
|
|
5686
|
+
{
|
|
5687
|
+
title: 'Session',
|
|
5688
|
+
groups: [
|
|
5689
|
+
{
|
|
5690
|
+
title: 'Session',
|
|
5691
|
+
rows: [
|
|
5692
|
+
{ keys: ['P'], label: 'Open plan' },
|
|
5693
|
+
{ keys: ['Ctrl', 'Shift', 'P'], combo: true, label: 'Session picker' },
|
|
5694
|
+
{ keys: ['Shift', 'P'], combo: true, label: 'Project picker (this board)' },
|
|
5695
|
+
{ keys: ['I'], label: 'Session info' },
|
|
5696
|
+
{ keys: ['.'], label: 'Pin / unpin' },
|
|
5697
|
+
{ keys: ['>'], label: 'Toggle sticky' },
|
|
5698
|
+
{ keys: ['Shift', 'F10'], combo: true, label: 'Move to group (or Menu key)' },
|
|
5699
|
+
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session' },
|
|
5700
|
+
{ keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
5701
|
+
{ keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
|
|
5702
|
+
{ keys: ['↑', '↓'], label: 'Previous / next message in detail' },
|
|
5703
|
+
{ keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt' },
|
|
5704
|
+
],
|
|
5705
|
+
},
|
|
5706
|
+
{
|
|
5707
|
+
title: 'Sessions',
|
|
5708
|
+
rows: [
|
|
5709
|
+
{ keys: ['Ctrl', 'Alt', 'N'], combo: true, label: 'New session' },
|
|
5710
|
+
{ keys: ['Ctrl', 'Alt', 'R'], combo: true, label: 'Resume session (claude -r)' },
|
|
5711
|
+
{ keys: ['Ctrl', 'Alt', 'S'], combo: true, label: 'Swap to previous session' },
|
|
5712
|
+
],
|
|
5713
|
+
},
|
|
5714
|
+
],
|
|
5715
|
+
},
|
|
5716
|
+
{
|
|
5717
|
+
title: 'Terminal',
|
|
5718
|
+
groups: [
|
|
5719
|
+
{
|
|
5720
|
+
title: 'Terminal',
|
|
5721
|
+
rows: [
|
|
5722
|
+
{ keys: ['Ctrl', '`'], combo: true, label: 'Show / hide terminal' },
|
|
5723
|
+
{ keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
|
|
5724
|
+
{ keys: ['Alt', 'Shift', '`'], combo: true, label: 'End and close terminal' },
|
|
5725
|
+
{ keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
|
|
5726
|
+
{ keys: ['Ctrl', '+/−/0'], combo: true, label: 'Terminal text size' },
|
|
5727
|
+
],
|
|
5728
|
+
},
|
|
5729
|
+
],
|
|
5730
|
+
},
|
|
5731
|
+
{
|
|
5732
|
+
title: 'Hub',
|
|
5733
|
+
hub: true,
|
|
5734
|
+
groups: [
|
|
5735
|
+
{
|
|
5736
|
+
title: 'Hub',
|
|
5737
|
+
rows: [
|
|
5738
|
+
{ keys: ['M'], label: 'Jump to marketplace' },
|
|
5739
|
+
{ keys: ['$'], label: 'Jump to cost' },
|
|
5740
|
+
{ keys: ['Ctrl', 'M'], combo: true, label: 'Jump to memory' },
|
|
5741
|
+
{ keys: ['Ctrl', 'Alt', '←/→'], combo: true, label: 'Previous / next hub app' },
|
|
5742
|
+
{ keys: ['Alt', '1…9'], combo: true, label: 'Jump to hub app by number' },
|
|
5743
|
+
{ keys: ['Ctrl', 'Alt', 'P'], combo: true, label: 'Project picker' },
|
|
5744
|
+
{ keys: ['Ctrl', 'Alt', 'W'], combo: true, label: 'Config dir picker' },
|
|
5745
|
+
{ keys: ['Ctrl', 'Alt', 'A'], combo: true, label: 'App launcher' },
|
|
5746
|
+
],
|
|
5747
|
+
},
|
|
5748
|
+
],
|
|
5749
|
+
},
|
|
5598
5750
|
];
|
|
5599
5751
|
|
|
5600
|
-
const EMPTY_GROUP = { title: '', rows: [] };
|
|
5601
|
-
|
|
5602
5752
|
// Interleaves each pair's rows left-then-right so CSS grid auto-placement lands
|
|
5603
5753
|
// them on shared row tracks (see .shortcuts in style.css).
|
|
5604
|
-
function buildHelpShortcuts() {
|
|
5754
|
+
function buildHelpShortcuts(groups) {
|
|
5605
5755
|
const cells = [];
|
|
5606
|
-
|
|
5756
|
+
for (let pair = 0; pair < groups.length; pair += 2) {
|
|
5607
5757
|
const first = pair === 0 ? ' sc-first' : '';
|
|
5608
5758
|
const sides = [
|
|
5609
|
-
[
|
|
5610
|
-
[
|
|
5611
|
-
];
|
|
5612
|
-
const
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
for (let i = 0; i < Math.max(left.rows.length, right.rows.length); i++) {
|
|
5759
|
+
[groups[pair], 'sc-l'],
|
|
5760
|
+
[groups[pair + 1], 'sc-r'],
|
|
5761
|
+
].filter(([g]) => g);
|
|
5762
|
+
for (const [g, side] of sides)
|
|
5763
|
+
cells.push(`<div class="${escapeHtml(`sc-group ${side}${first}`)}">${escapeHtml(g.title)}</div>`);
|
|
5764
|
+
for (let i = 0; i < Math.max(...sides.map(([g]) => g.rows.length)); i++) {
|
|
5616
5765
|
for (const [group, side] of sides) {
|
|
5617
5766
|
const row = group.rows[i];
|
|
5618
5767
|
if (!row) continue;
|
|
5619
5768
|
const sep = row.combo ? '<span class="sc-plus">+</span>' : '<span class="sc-or">/</span>';
|
|
5620
5769
|
const keys = row.keys.map((k) => `<kbd>${escapeHtml(k)}</kbd>`).join(sep);
|
|
5621
|
-
const
|
|
5622
|
-
cells.push(
|
|
5623
|
-
`<dt class="${escapeHtml(cls)}">${keys}</dt><dd class="${escapeHtml(cls)}">${escapeHtml(row.label)}</dd>`,
|
|
5624
|
-
);
|
|
5770
|
+
const label = escapeHtml(row.label);
|
|
5771
|
+
cells.push(`<dt class="${escapeHtml(side)}">${keys}</dt><dd class="${escapeHtml(side)}">${label}</dd>`);
|
|
5625
5772
|
}
|
|
5626
5773
|
}
|
|
5627
|
-
}
|
|
5774
|
+
}
|
|
5628
5775
|
return cells.join('');
|
|
5629
5776
|
}
|
|
5630
5777
|
|
|
5778
|
+
let helpTab = 0;
|
|
5779
|
+
const helpFilter = document.getElementById('help-filter');
|
|
5780
|
+
|
|
5781
|
+
// Hub keys do nothing outside Claude Code Hub, so the standalone board drops that tab.
|
|
5782
|
+
const helpTabs = () => SHORTCUT_TABS.filter((t) => hub.inHub || !t.hub);
|
|
5783
|
+
|
|
5784
|
+
// The matching rows of every tab, in their groups.
|
|
5785
|
+
function filteredShortcutGroups(query) {
|
|
5786
|
+
return helpTabs()
|
|
5787
|
+
.flatMap((t) => t.groups)
|
|
5788
|
+
.map((g) => ({ title: g.title, rows: g.rows.filter((r) => fuzzyMatch(`${r.label} ${r.keys.join(' ')}`, query)) }))
|
|
5789
|
+
.filter((g) => g.rows.length);
|
|
5790
|
+
}
|
|
5791
|
+
|
|
5792
|
+
function renderHelp() {
|
|
5793
|
+
const tabs = helpTabs();
|
|
5794
|
+
helpTab = Math.min(helpTab, tabs.length - 1);
|
|
5795
|
+
const query = helpFilter.value.trim();
|
|
5796
|
+
document.getElementById('help-tabs').innerHTML = tabs
|
|
5797
|
+
.map((t, i) => {
|
|
5798
|
+
const on = !query && i === helpTab;
|
|
5799
|
+
return `<button type="button" role="tab" class="help-tab${on ? ' active' : ''}" aria-selected="${escapeHtml(String(on))}" data-idx="${i}">${escapeHtml(t.title)}</button>`;
|
|
5800
|
+
})
|
|
5801
|
+
.join('');
|
|
5802
|
+
const groups = query ? filteredShortcutGroups(query) : tabs[helpTab].groups;
|
|
5803
|
+
document.getElementById('help-shortcuts').innerHTML = groups.length
|
|
5804
|
+
? buildHelpShortcuts(groups)
|
|
5805
|
+
: `<div class="sc-empty">No shortcut matches "${escapeHtml(query)}"</div>`;
|
|
5806
|
+
}
|
|
5807
|
+
|
|
5808
|
+
function clearHelpFilter() {
|
|
5809
|
+
helpFilter.value = '';
|
|
5810
|
+
renderHelp();
|
|
5811
|
+
}
|
|
5812
|
+
|
|
5813
|
+
function selectHelpTab(idx) {
|
|
5814
|
+
helpTab = idx;
|
|
5815
|
+
clearHelpFilter();
|
|
5816
|
+
}
|
|
5817
|
+
|
|
5631
5818
|
function showHelpModal() {
|
|
5632
|
-
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
list.classList.toggle('sc-standalone', !window.__HUB__?.enabled);
|
|
5636
|
-
modal.classList.add('visible');
|
|
5819
|
+
clearHelpFilter();
|
|
5820
|
+
document.getElementById('help-modal').classList.add('visible');
|
|
5821
|
+
helpFilter.focus();
|
|
5637
5822
|
}
|
|
5638
5823
|
|
|
5824
|
+
document.getElementById('help-tabs').addEventListener('click', (e) => {
|
|
5825
|
+
const tab = e.target.closest('[data-idx]');
|
|
5826
|
+
if (!tab) return;
|
|
5827
|
+
selectHelpTab(Number(tab.dataset.idx));
|
|
5828
|
+
helpFilter.focus();
|
|
5829
|
+
});
|
|
5830
|
+
|
|
5831
|
+
helpFilter.addEventListener('input', renderHelp);
|
|
5832
|
+
|
|
5833
|
+
// The page's key handler skips text fields, so the filter handles its own Esc and tab keys.
|
|
5834
|
+
helpFilter.addEventListener('keydown', (e) => {
|
|
5835
|
+
if (e.key === 'Escape') {
|
|
5836
|
+
e.preventDefault();
|
|
5837
|
+
e.stopPropagation();
|
|
5838
|
+
if (helpFilter.value) clearHelpFilter();
|
|
5839
|
+
else closeHelpModal();
|
|
5840
|
+
return;
|
|
5841
|
+
}
|
|
5842
|
+
const step = matchKey(e, 'ArrowLeft') ? -1 : matchKey(e, 'ArrowRight') ? 1 : 0;
|
|
5843
|
+
if (!step || helpFilter.value) return;
|
|
5844
|
+
e.preventDefault();
|
|
5845
|
+
const n = helpTabs().length;
|
|
5846
|
+
selectHelpTab((helpTab + step + n) % n);
|
|
5847
|
+
});
|
|
5848
|
+
|
|
5639
5849
|
function closeHelpModal() {
|
|
5640
5850
|
const modal = document.getElementById('help-modal');
|
|
5641
5851
|
modal.classList.remove('visible');
|
|
@@ -6275,6 +6485,7 @@ const MODAL_CLOSERS = {
|
|
|
6275
6485
|
'agent-modal': () => closeAgentModal(),
|
|
6276
6486
|
'help-modal': () => closeHelpModal(),
|
|
6277
6487
|
'session-picker-modal': () => closeSessionPicker(),
|
|
6488
|
+
'project-picker-modal': () => closeProjectPicker(),
|
|
6278
6489
|
'terminal-manager-modal': () => closeTerminalManager(),
|
|
6279
6490
|
'new-session-modal': () => closeNewSession(),
|
|
6280
6491
|
};
|
|
@@ -6395,6 +6606,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
6395
6606
|
toggleMessagePanel();
|
|
6396
6607
|
return;
|
|
6397
6608
|
}
|
|
6609
|
+
if (e.code === 'KeyP' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
6610
|
+
e.preventDefault();
|
|
6611
|
+
openProjectPicker();
|
|
6612
|
+
return;
|
|
6613
|
+
}
|
|
6398
6614
|
if (e.code === 'KeyM' && e.shiftKey) {
|
|
6399
6615
|
e.preventDefault();
|
|
6400
6616
|
const msgDetailModal = document.getElementById('msg-detail-modal');
|
|
@@ -6562,19 +6778,19 @@ document.addEventListener('keydown', (e) => {
|
|
|
6562
6778
|
}
|
|
6563
6779
|
if (e.key === '$' && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
6564
6780
|
e.preventDefault();
|
|
6565
|
-
|
|
6781
|
+
openCost(contextSid);
|
|
6566
6782
|
return;
|
|
6567
6783
|
}
|
|
6568
6784
|
if (matchKey(e, 'KeyM')) {
|
|
6569
6785
|
e.preventDefault();
|
|
6570
6786
|
const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
|
|
6571
|
-
|
|
6787
|
+
openMarketplace(mSession?.project);
|
|
6572
6788
|
return;
|
|
6573
6789
|
}
|
|
6574
6790
|
if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'm') {
|
|
6575
6791
|
e.preventDefault();
|
|
6576
6792
|
const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
|
|
6577
|
-
|
|
6793
|
+
openMemory(mSession?.project);
|
|
6578
6794
|
return;
|
|
6579
6795
|
}
|
|
6580
6796
|
if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
|
|
@@ -6608,11 +6824,17 @@ document.addEventListener('keydown', (e) => {
|
|
|
6608
6824
|
showToast('No session selected');
|
|
6609
6825
|
return;
|
|
6610
6826
|
}
|
|
6611
|
-
const
|
|
6612
|
-
const label = e.ctrlKey
|
|
6827
|
+
const peerName = e.ctrlKey ? sessions.find((s) => s.id === contextSid)?.peerName : null;
|
|
6828
|
+
const [text, label] = e.ctrlKey
|
|
6829
|
+
? [peerName && sendMessageAddress(peerName), 'session name']
|
|
6830
|
+
: [contextSid, 'session id'];
|
|
6831
|
+
if (!text) {
|
|
6832
|
+
showToast('No live session name: the session is not running');
|
|
6833
|
+
return;
|
|
6834
|
+
}
|
|
6613
6835
|
navigator.clipboard
|
|
6614
6836
|
.writeText(text)
|
|
6615
|
-
.then(() => showToast(`Copied ${label}: ${
|
|
6837
|
+
.then(() => showToast(`Copied ${label}: ${e.ctrlKey ? text : text.slice(0, 8)}`, 'success'))
|
|
6616
6838
|
.catch(() => showToast(`Failed to copy ${label}`));
|
|
6617
6839
|
return;
|
|
6618
6840
|
}
|
|
@@ -6939,6 +7161,7 @@ function handleSessionOpenEvent(data) {
|
|
|
6939
7161
|
stickySessionIds.add(id);
|
|
6940
7162
|
}
|
|
6941
7163
|
setSessionDismissed(id, false);
|
|
7164
|
+
cliOpenedId = id;
|
|
6942
7165
|
fetchTasks(id);
|
|
6943
7166
|
}
|
|
6944
7167
|
|
|
@@ -6963,7 +7186,7 @@ function handleDocumentLinkEvent(data) {
|
|
|
6963
7186
|
setSessionDocLink(sessionId, filePath, unlink);
|
|
6964
7187
|
const msg = `${unlink ? 'Unlinked' : 'Linked'} ${linkedDocLabel(filePath)}`;
|
|
6965
7188
|
// Every tab gets the event; only the one on screen offers to open the URL.
|
|
6966
|
-
if (open && isOnScreen()) showToast(msg, 'info', { label: 'Open', onClick: () => openExternal(filePath) });
|
|
7189
|
+
if (open && isOnScreen()) showToast(msg, 'info', { label: 'Open', onClick: () => hub.openExternal(filePath) });
|
|
6967
7190
|
else showToast(msg);
|
|
6968
7191
|
}
|
|
6969
7192
|
|
|
@@ -7090,7 +7313,7 @@ function linkedDocLabel(p) {
|
|
|
7090
7313
|
function openLinkedDoc(p, baseDir) {
|
|
7091
7314
|
const opener = linkedDocOpener(p);
|
|
7092
7315
|
if (opener === 'url') {
|
|
7093
|
-
openExternal(p);
|
|
7316
|
+
hub.openExternal(p);
|
|
7094
7317
|
return;
|
|
7095
7318
|
}
|
|
7096
7319
|
if (opener === 'preview') {
|
|
@@ -7606,7 +7829,7 @@ function setupEventSource() {
|
|
|
7606
7829
|
if (data.type === 'dispatch-update') onDispatchUpdate();
|
|
7607
7830
|
|
|
7608
7831
|
if (data.type === 'agent-update') {
|
|
7609
|
-
pendingAgentSessionIds.add(data.sessionId);
|
|
7832
|
+
if (!data.unreadOnly) pendingAgentSessionIds.add(data.sessionId);
|
|
7610
7833
|
clearTimeout(agentRefreshTimer);
|
|
7611
7834
|
agentRefreshTimer = setTimeout(() => {
|
|
7612
7835
|
if (skipOffScreen()) return;
|
|
@@ -7658,6 +7881,7 @@ function setupEventSource() {
|
|
|
7658
7881
|
|
|
7659
7882
|
// When cck comes back on screen (tab visible or hub:active), catch up immediately
|
|
7660
7883
|
async function catchUp() {
|
|
7884
|
+
markOpenSessionRead();
|
|
7661
7885
|
if (!isOnScreen() || !missedWhileHidden) return;
|
|
7662
7886
|
missedWhileHidden = false;
|
|
7663
7887
|
// The full refresh below covers whatever the pending debounces would have fetched.
|
|
@@ -7678,12 +7902,11 @@ function setupEventSource() {
|
|
|
7678
7902
|
renderAgentFooter();
|
|
7679
7903
|
}
|
|
7680
7904
|
document.addEventListener('visibilitychange', catchUp);
|
|
7681
|
-
|
|
7682
|
-
|
|
7683
|
-
hubActive = !!e.data.active;
|
|
7905
|
+
onHubActive = (active) => {
|
|
7906
|
+
hubActive = active;
|
|
7684
7907
|
catchUp();
|
|
7685
7908
|
if (hubActive && wantsTerminal()) onTerminalShown();
|
|
7686
|
-
}
|
|
7909
|
+
};
|
|
7687
7910
|
|
|
7688
7911
|
// Fallback poll every 30s in case SSE silently drops; skip when not on screen
|
|
7689
7912
|
setInterval(() => {
|
|
@@ -8099,7 +8322,7 @@ function formatDate(dateStr) {
|
|
|
8099
8322
|
const now = new Date();
|
|
8100
8323
|
const diff = now - date;
|
|
8101
8324
|
|
|
8102
|
-
if (diff <
|
|
8325
|
+
if (diff < JUST_NOW_MS) return 'just now';
|
|
8103
8326
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
8104
8327
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
|
8105
8328
|
return date.toLocaleDateString();
|
|
@@ -8680,12 +8903,10 @@ function renderZenState() {
|
|
|
8680
8903
|
: 'Zen mode: current session only (Ctrl+Shift+Z)';
|
|
8681
8904
|
}
|
|
8682
8905
|
|
|
8683
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
8684
8906
|
function filterBySessions(value) {
|
|
8685
8907
|
if (value === sessionFilter) return;
|
|
8686
|
-
|
|
8908
|
+
setSessionFilter(value);
|
|
8687
8909
|
resetSessionPage();
|
|
8688
|
-
if (value !== 'active') activityFilter.clear();
|
|
8689
8910
|
renderFilterState();
|
|
8690
8911
|
updateUrl();
|
|
8691
8912
|
// Instant feedback from cached data, then refetch — the cached list was fetched
|
|
@@ -8696,6 +8917,11 @@ function filterBySessions(value) {
|
|
|
8696
8917
|
fetchSessions(false);
|
|
8697
8918
|
}
|
|
8698
8919
|
|
|
8920
|
+
function setSessionFilter(value) {
|
|
8921
|
+
sessionFilter = value;
|
|
8922
|
+
if (value !== 'active') activityFilter.clear();
|
|
8923
|
+
}
|
|
8924
|
+
|
|
8699
8925
|
function projectFilterText() {
|
|
8700
8926
|
return filterProject && filterProject !== '__recent__' ? filterProject : '';
|
|
8701
8927
|
}
|
|
@@ -8973,15 +9199,17 @@ const COLOR_THEMES = [
|
|
|
8973
9199
|
['synthwave', "Synthwave '84"],
|
|
8974
9200
|
];
|
|
8975
9201
|
|
|
8976
|
-
function buildThemeMenu() {
|
|
9202
|
+
function buildThemeMenu(themes = COLOR_THEMES) {
|
|
8977
9203
|
const menu = document.getElementById('themeMenu');
|
|
8978
|
-
menu.innerHTML =
|
|
8979
|
-
(
|
|
8980
|
-
|
|
9204
|
+
menu.innerHTML = themes
|
|
9205
|
+
.map(
|
|
9206
|
+
([id, label]) =>
|
|
9207
|
+
`<button type="button" class="theme-menu-item theme-swatch-${id}" data-theme-id="${id}"
|
|
8981
9208
|
onclick="event.stopPropagation(); setColorTheme('${id}'); toggleThemeMenu()">
|
|
8982
|
-
<span class="theme-swatch theme-swatch-${id}"><i class="sw-bg"></i><i class="sw-accent"></i><i class="sw-ink"></i></span>${label}
|
|
9209
|
+
<span class="theme-swatch theme-swatch-${id}"><i class="sw-bg"></i><i class="sw-accent"></i><i class="sw-ink"></i></span>${escapeHtml(label)}
|
|
8983
9210
|
</button>`,
|
|
8984
|
-
|
|
9211
|
+
)
|
|
9212
|
+
.join('');
|
|
8985
9213
|
}
|
|
8986
9214
|
|
|
8987
9215
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
@@ -9241,10 +9469,13 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
9241
9469
|
let html = '';
|
|
9242
9470
|
|
|
9243
9471
|
// Session & project details as compact key-value rows
|
|
9244
|
-
// Each row: [label, value, { openPath?, abbrev? }] — `value` is authoritative
|
|
9245
|
-
// (tooltip + copy); `abbrev` only replaces the rendered text.
|
|
9472
|
+
// Each row: [label, value, { openPath?, abbrev?, copy? }] — `value` is authoritative
|
|
9473
|
+
// (tooltip + copy); `abbrev` only replaces the rendered text, `copy` the copied text.
|
|
9246
9474
|
const infoRows = [];
|
|
9247
9475
|
infoRows.push(['Session', session.id, { openClaudeDir: true, openFile: session.jsonlPath }]);
|
|
9476
|
+
if (session.peerName) {
|
|
9477
|
+
infoRows.push(['Peer name', session.peerName, { copy: sendMessageAddress(session.peerName) }]);
|
|
9478
|
+
}
|
|
9248
9479
|
if (parentInfo?.parentSessionId) {
|
|
9249
9480
|
infoRows.push([
|
|
9250
9481
|
parentInfo.relation === 'compact' ? 'Continued from' : 'Forked from',
|
|
@@ -9298,7 +9529,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
9298
9529
|
} else {
|
|
9299
9530
|
html += `<span style="${plainStyle}" title="${escapeHtml(value)}">${escapeHtml(opts?.abbrev || value)}</span>`;
|
|
9300
9531
|
}
|
|
9301
|
-
const copyBtn = `<button onclick="copyWithFeedback('${escAttrJs(value)}', this)" title="Copy">${ICON_COPY}</button>`;
|
|
9532
|
+
const copyBtn = `<button onclick="copyWithFeedback('${escAttrJs(opts?.copy || value)}', this)" title="Copy">${ICON_COPY}</button>`;
|
|
9302
9533
|
let openBtn = '';
|
|
9303
9534
|
if (opts?.openClaudeDir || opts?.openPath) {
|
|
9304
9535
|
const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath);
|
|
@@ -9393,12 +9624,12 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
9393
9624
|
updateStickyBtnState();
|
|
9394
9625
|
updateDismissBtnState();
|
|
9395
9626
|
const costBtn = document.getElementById('session-info-cost-btn');
|
|
9396
|
-
if (costBtn) costBtn.style.display =
|
|
9627
|
+
if (costBtn) costBtn.style.display = hub.can('session.cost') ? '' : 'none';
|
|
9397
9628
|
const mkBtn = document.getElementById('session-info-marketplace-btn');
|
|
9398
9629
|
const memBtn = document.getElementById('session-info-memory-btn');
|
|
9399
9630
|
const proj = session.project;
|
|
9400
|
-
if (mkBtn) mkBtn.style.display = proj && (
|
|
9401
|
-
if (memBtn) memBtn.style.display = proj && (
|
|
9631
|
+
if (mkBtn) mkBtn.style.display = proj && hub.can('project.plugins') ? '' : 'none';
|
|
9632
|
+
if (memBtn) memBtn.style.display = proj && hub.can('project.memory') ? '' : 'none';
|
|
9402
9633
|
modal.classList.add('visible');
|
|
9403
9634
|
|
|
9404
9635
|
if (alreadyVisible) return; // re-render during deferred hydration — key handler already attached
|
|
@@ -9512,8 +9743,12 @@ function closeSessionPicker({ refocus = true } = {}) {
|
|
|
9512
9743
|
if (refocus && spFromTerminal && wantsTerminal()) focusTerminalPane();
|
|
9513
9744
|
}
|
|
9514
9745
|
|
|
9746
|
+
function isSessionPickerOpen() {
|
|
9747
|
+
return document.getElementById('session-picker-modal').classList.contains('visible');
|
|
9748
|
+
}
|
|
9749
|
+
|
|
9515
9750
|
function toggleSessionPicker() {
|
|
9516
|
-
if (
|
|
9751
|
+
if (isSessionPickerOpen()) closeSessionPicker();
|
|
9517
9752
|
else openSessionPicker();
|
|
9518
9753
|
}
|
|
9519
9754
|
|
|
@@ -9580,17 +9815,35 @@ function spMatches(session, query) {
|
|
|
9580
9815
|
}
|
|
9581
9816
|
|
|
9582
9817
|
// Spelled out rather than interpolated so the class never comes from a variable.
|
|
9583
|
-
const
|
|
9584
|
-
waiting: '<span class="
|
|
9585
|
-
live: '<span class="
|
|
9586
|
-
active: '<span class="
|
|
9587
|
-
|
|
9818
|
+
const SP_STATES = {
|
|
9819
|
+
waiting: '<span class="sp-state waiting">',
|
|
9820
|
+
live: '<span class="sp-state live">',
|
|
9821
|
+
active: '<span class="sp-state active">',
|
|
9822
|
+
justFinished: '<span class="sp-state just-finished">',
|
|
9823
|
+
recent: '<span class="sp-state recent">',
|
|
9824
|
+
idle: '<span class="sp-state idle">',
|
|
9588
9825
|
};
|
|
9589
9826
|
|
|
9827
|
+
const SP_DOT = '<span class="activity-dot"></span>';
|
|
9828
|
+
const SP_TERMINAL = `<span class="sp-terminal" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>`;
|
|
9829
|
+
|
|
9830
|
+
function spState(session) {
|
|
9831
|
+
if (isWaitingSession(session)) return 'waiting';
|
|
9832
|
+
if (isSessionLive(session)) return 'live';
|
|
9833
|
+
if (isActiveSession(session)) return 'active';
|
|
9834
|
+
if (isJustFinished(session)) return 'justFinished';
|
|
9835
|
+
return session.hasRecentActivity || session.inProgress > 0 ? 'recent' : 'idle';
|
|
9836
|
+
}
|
|
9837
|
+
|
|
9590
9838
|
function spDotHtml(session) {
|
|
9591
|
-
|
|
9592
|
-
|
|
9593
|
-
|
|
9839
|
+
return `${SP_STATES[spState(session)]}${runningTerminals.has(session.id) ? SP_TERMINAL : SP_DOT}</span>`;
|
|
9840
|
+
}
|
|
9841
|
+
|
|
9842
|
+
function spPinHtml(session) {
|
|
9843
|
+
const state = getSessionPinState(session.id);
|
|
9844
|
+
if (state === 'sticky') return `<span class="sp-pin sticky" title="Sticky">${SESSION_STAR_SVG}</span>`;
|
|
9845
|
+
if (state === 'pinned') return `<span class="sp-pin pinned" title="Pinned">${SESSION_PIN_SVG}</span>`;
|
|
9846
|
+
return '<span class="sp-pin"></span>';
|
|
9594
9847
|
}
|
|
9595
9848
|
|
|
9596
9849
|
function renderSessionPicker() {
|
|
@@ -9626,6 +9879,7 @@ function renderSessionPicker() {
|
|
|
9626
9879
|
${s.gitBranch ? `<span class="sp-branch">${escapeHtml(s.gitBranch)}</span>` : ''}
|
|
9627
9880
|
<span class="sp-count">${s.completed}/${s.taskCount}</span>
|
|
9628
9881
|
<span class="sp-time">${formatDate(s.modifiedAt)}</span>
|
|
9882
|
+
${spPinHtml(s)}
|
|
9629
9883
|
</button>`;
|
|
9630
9884
|
})
|
|
9631
9885
|
.join('');
|
|
@@ -9685,6 +9939,122 @@ function initSessionPicker() {
|
|
|
9685
9939
|
}
|
|
9686
9940
|
//#endregion
|
|
9687
9941
|
|
|
9942
|
+
//#region PROJECT_PICKER
|
|
9943
|
+
// Sets only this board's project filter; a later hub project.changed replaces it.
|
|
9944
|
+
let ppRows = [];
|
|
9945
|
+
let ppIdx = 0;
|
|
9946
|
+
let ppSource = null;
|
|
9947
|
+
let ppRecentOnly = false;
|
|
9948
|
+
|
|
9949
|
+
async function openProjectPicker() {
|
|
9950
|
+
const input = document.getElementById('project-picker-input');
|
|
9951
|
+
input.value = '';
|
|
9952
|
+
renderProjectPicker();
|
|
9953
|
+
document.getElementById('project-picker-modal').classList.add('visible');
|
|
9954
|
+
input.focus();
|
|
9955
|
+
await refreshProjectList();
|
|
9956
|
+
ppSource = (projectsCache || [])
|
|
9957
|
+
.filter((p) => !p.temp)
|
|
9958
|
+
.map((p) => ({ ...p, norm: normalizeProjectPath(p.path) }))
|
|
9959
|
+
.sort((a, b) => (b.modifiedAt || '').localeCompare(a.modifiedAt || ''));
|
|
9960
|
+
renderProjectPicker();
|
|
9961
|
+
}
|
|
9962
|
+
|
|
9963
|
+
function ppToggleRecentOnly() {
|
|
9964
|
+
ppRecentOnly = !ppRecentOnly;
|
|
9965
|
+
document.getElementById('pp-recent-chip').setAttribute('aria-pressed', String(ppRecentOnly));
|
|
9966
|
+
renderProjectPicker();
|
|
9967
|
+
document.getElementById('project-picker-input').focus();
|
|
9968
|
+
}
|
|
9969
|
+
|
|
9970
|
+
// One fetch for both filters: two in a row could land out of order.
|
|
9971
|
+
function ppSetFilters(project, session) {
|
|
9972
|
+
closeProjectPicker();
|
|
9973
|
+
if (project === filterProject) return filterBySessions(session);
|
|
9974
|
+
setSessionFilter(session);
|
|
9975
|
+
filterByProject(project);
|
|
9976
|
+
}
|
|
9977
|
+
|
|
9978
|
+
function closeProjectPicker() {
|
|
9979
|
+
hideModalOverlay('project-picker-modal');
|
|
9980
|
+
}
|
|
9981
|
+
|
|
9982
|
+
function renderProjectPicker() {
|
|
9983
|
+
const list = document.getElementById('project-picker-list');
|
|
9984
|
+
const query = document.getElementById('project-picker-input').value.trim();
|
|
9985
|
+
const source = ppSource || [];
|
|
9986
|
+
const pool = ppRecentOnly ? source.filter((p) => recentProjects.has(p.path)) : source;
|
|
9987
|
+
ppRows = query ? pool.filter((p) => fuzzyMatch(p.path, query)) : pool;
|
|
9988
|
+
if (!ppRows.length) {
|
|
9989
|
+
ppIdx = -1;
|
|
9990
|
+
const empty = ppRecentOnly ? `No project active in the last ${RECENT_PROJECT_HOURS}h` : 'No project matches';
|
|
9991
|
+
list.innerHTML = `<div class="sp-empty">${ppSource ? empty : 'Loading projects…'}</div>`;
|
|
9992
|
+
return;
|
|
9993
|
+
}
|
|
9994
|
+
const current = filterProject && normalizeProjectPath(filterProject);
|
|
9995
|
+
list.innerHTML = ppRows
|
|
9996
|
+
.map((p, i) => {
|
|
9997
|
+
return `<button class="sp-row${p.norm === current ? ' current' : ''}${recentProjects.has(p.path) ? ' recent' : ''}" data-idx="${i}">
|
|
9998
|
+
<span class="sp-name">${escapeHtml(pathBasename(p.path))}</span>
|
|
9999
|
+
<span class="sp-project">${escapeHtml(p.path)}</span>
|
|
10000
|
+
${p.modifiedAt ? `<span class="sp-time">${formatDate(p.modifiedAt)}</span>` : ''}
|
|
10001
|
+
</button>`;
|
|
10002
|
+
})
|
|
10003
|
+
.join('');
|
|
10004
|
+
const currentIdx = ppRows.findIndex((p) => p.norm === current);
|
|
10005
|
+
ppSelect(query ? 0 : Math.max(0, currentIdx));
|
|
10006
|
+
}
|
|
10007
|
+
|
|
10008
|
+
function ppSelect(idx) {
|
|
10009
|
+
const list = document.getElementById('project-picker-list');
|
|
10010
|
+
list.children[ppIdx]?.classList.remove('selected');
|
|
10011
|
+
ppIdx = Math.min(Math.max(idx, 0), ppRows.length - 1);
|
|
10012
|
+
const row = list.children[ppIdx];
|
|
10013
|
+
row?.classList.add('selected');
|
|
10014
|
+
row?.scrollIntoView({ block: 'nearest' });
|
|
10015
|
+
}
|
|
10016
|
+
|
|
10017
|
+
function ppApply(idx) {
|
|
10018
|
+
const row = ppRows[idx];
|
|
10019
|
+
if (!row) return;
|
|
10020
|
+
ppSetFilters(row.path, ppRecentOnly ? 'active' : 'all');
|
|
10021
|
+
}
|
|
10022
|
+
|
|
10023
|
+
function initProjectPicker() {
|
|
10024
|
+
const input = document.getElementById('project-picker-input');
|
|
10025
|
+
input.addEventListener('input', renderProjectPicker);
|
|
10026
|
+
input.addEventListener('keydown', (e) => {
|
|
10027
|
+
if (e.key === 'Escape') closeProjectPicker();
|
|
10028
|
+
else if (matchKey(e, 'ArrowDown') || (e.ctrlKey && e.key === 'n')) ppSelect(ppIdx + 1);
|
|
10029
|
+
else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && !e.shiftKey && e.key === 'p')) ppSelect(ppIdx - 1);
|
|
10030
|
+
else if (e.key === 'Enter') ppApply(ppIdx);
|
|
10031
|
+
else if (e.altKey && !e.ctrlKey && !e.metaKey && e.code === 'KeyR') {
|
|
10032
|
+
if (!e.repeat) ppToggleRecentOnly();
|
|
10033
|
+
} else if (e.altKey && !e.ctrlKey && !e.metaKey && e.code === 'KeyC')
|
|
10034
|
+
ppSetFilters(FILTER_DEFAULTS.project, FILTER_DEFAULTS.session);
|
|
10035
|
+
else return;
|
|
10036
|
+
e.preventDefault();
|
|
10037
|
+
e.stopPropagation();
|
|
10038
|
+
});
|
|
10039
|
+
document.getElementById('project-picker-list').addEventListener('click', (e) => {
|
|
10040
|
+
const row = e.target.closest('.sp-row');
|
|
10041
|
+
if (row) ppApply(Number(row.dataset.idx));
|
|
10042
|
+
});
|
|
10043
|
+
// Vimium's Escape only blurs the input; see initSessionPicker.
|
|
10044
|
+
const modal = document.getElementById('project-picker-modal');
|
|
10045
|
+
let pointerDown = false;
|
|
10046
|
+
modal.addEventListener('mousedown', () => {
|
|
10047
|
+
pointerDown = true;
|
|
10048
|
+
});
|
|
10049
|
+
document.addEventListener('mouseup', () => {
|
|
10050
|
+
pointerDown = false;
|
|
10051
|
+
});
|
|
10052
|
+
input.addEventListener('blur', () => {
|
|
10053
|
+
if (!pointerDown && modal.classList.contains('visible') && document.hasFocus()) closeProjectPicker();
|
|
10054
|
+
});
|
|
10055
|
+
}
|
|
10056
|
+
//#endregion
|
|
10057
|
+
|
|
9688
10058
|
//#region PLAN
|
|
9689
10059
|
function refreshOpenPlan() {
|
|
9690
10060
|
if (!_planSessionId || !document.getElementById('plan-modal').classList.contains('visible')) return;
|
|
@@ -10062,35 +10432,16 @@ function openFolderInEditor(folder, file) {
|
|
|
10062
10432
|
postAndToast('/api/open-folder', body, 'folder');
|
|
10063
10433
|
}
|
|
10064
10434
|
|
|
10065
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
10066
10435
|
function openCost(sessionId) {
|
|
10067
|
-
|
|
10068
|
-
hubNavigate('cost', `?view=detail&session=${encodeURIComponent(sessionId)}`);
|
|
10069
|
-
} else if (appConfig.costUrl) {
|
|
10070
|
-
window.open(`${appConfig.costUrl}?view=detail&session=${encodeURIComponent(sessionId)}`, '_blank');
|
|
10071
|
-
}
|
|
10436
|
+
hub.invoke('session.cost', sessionId ? { session: sessionId } : {});
|
|
10072
10437
|
}
|
|
10073
10438
|
|
|
10074
10439
|
function openMarketplace(projectPath) {
|
|
10075
|
-
|
|
10076
|
-
if (window.__HUB__?.enabled) {
|
|
10077
|
-
hubNavigate('marketplace', `?${params}`);
|
|
10078
|
-
} else if (appConfig.marketplaceUrl) {
|
|
10079
|
-
const url = new URL(appConfig.marketplaceUrl);
|
|
10080
|
-
url.search = params.toString();
|
|
10081
|
-
window.open(url.toString(), '_blank');
|
|
10082
|
-
}
|
|
10440
|
+
hub.invoke('project.plugins', projectPath ? { project: projectPath } : {});
|
|
10083
10441
|
}
|
|
10084
10442
|
|
|
10085
10443
|
function openMemory(projectPath) {
|
|
10086
|
-
|
|
10087
|
-
if (window.__HUB__?.enabled) {
|
|
10088
|
-
hubNavigate('memory', `?${params}`);
|
|
10089
|
-
} else if (appConfig.memoryUrl) {
|
|
10090
|
-
const url = new URL(appConfig.memoryUrl);
|
|
10091
|
-
url.search = params.toString();
|
|
10092
|
-
window.open(url.toString(), '_blank');
|
|
10093
|
-
}
|
|
10444
|
+
hub.invoke('project.memory', projectPath ? { project: projectPath } : {});
|
|
10094
10445
|
}
|
|
10095
10446
|
|
|
10096
10447
|
function openForInfoModalProject(open) {
|
|
@@ -10262,10 +10613,9 @@ function filterByOwner(value) {
|
|
|
10262
10613
|
// leaving the session view or switching sessions drops only the socket; coming back
|
|
10263
10614
|
// reattaches and the server replays the screen.
|
|
10264
10615
|
const TERMINAL_TOKEN_KEY = 'terminal-token';
|
|
10616
|
+
// Every config dir's board is served on the hub's one kanban origin, so sessionStorage is shared.
|
|
10617
|
+
const TERMINAL_FOCUS_KEY = nsKey('terminal-focus');
|
|
10265
10618
|
const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
10266
|
-
// Ctrl+Alt letters cck keeps instead of forwarding to the hub: New session, Resume session, and
|
|
10267
|
-
// Swap to the previous session.
|
|
10268
|
-
const CCK_CTRL_ALT_KEYS = new Set(['KeyN', 'KeyR', 'KeyS']);
|
|
10269
10619
|
const TERMINAL_MODES_KEY = 'terminal-sessions';
|
|
10270
10620
|
const TERMINAL_FONT_KEY = 'terminal-font-size';
|
|
10271
10621
|
const TERMINAL_FONT_MIN = 8;
|
|
@@ -10322,32 +10672,18 @@ let tokenRefresh = null;
|
|
|
10322
10672
|
// The hub mints a new token each run, and this page can outlive a hub restart. Resolves true only
|
|
10323
10673
|
// for a token that differs from the one that just failed, so a caller retries at most once.
|
|
10324
10674
|
function refreshTerminalToken() {
|
|
10325
|
-
|
|
10326
|
-
|
|
10327
|
-
|
|
10328
|
-
|
|
10329
|
-
return tokenRefresh;
|
|
10330
|
-
}
|
|
10331
|
-
|
|
10332
|
-
function requestTerminalToken() {
|
|
10333
|
-
return new Promise((resolve) => {
|
|
10334
|
-
const done = (ok) => {
|
|
10335
|
-
window.removeEventListener('message', onMessage);
|
|
10336
|
-
clearTimeout(timer);
|
|
10337
|
-
resolve(ok);
|
|
10338
|
-
};
|
|
10339
|
-
const onMessage = (e) => {
|
|
10340
|
-
if (e.source !== window.parent || e.origin !== hubOrigin() || e.data?.type !== 'hub:terminalToken') return;
|
|
10341
|
-
const token = e.data.token;
|
|
10342
|
-
if (typeof token !== 'string' || !TERMINAL_TOKEN_RE.test(token) || token === terminalToken) return done(false);
|
|
10675
|
+
tokenRefresh ??= hub
|
|
10676
|
+
.terminalToken()
|
|
10677
|
+
.then((token) => {
|
|
10678
|
+
if (!TERMINAL_TOKEN_RE.test(token) || token === terminalToken) return false;
|
|
10343
10679
|
terminalToken = token;
|
|
10344
10680
|
storeTerminalToken(token);
|
|
10345
|
-
|
|
10346
|
-
}
|
|
10347
|
-
|
|
10348
|
-
|
|
10349
|
-
|
|
10350
|
-
|
|
10681
|
+
return true;
|
|
10682
|
+
})
|
|
10683
|
+
.finally(() => {
|
|
10684
|
+
tokenRefresh = null;
|
|
10685
|
+
});
|
|
10686
|
+
return tokenRefresh;
|
|
10351
10687
|
}
|
|
10352
10688
|
|
|
10353
10689
|
function terminalAvailable() {
|
|
@@ -10384,6 +10720,11 @@ function terminalPaneFocused() {
|
|
|
10384
10720
|
|
|
10385
10721
|
const TEXT_FIELD_SELECTOR = 'input, textarea, select, [contenteditable]';
|
|
10386
10722
|
|
|
10723
|
+
function inPageField(e) {
|
|
10724
|
+
const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
|
|
10725
|
+
return !!field && !document.getElementById('terminal-pane').contains(field);
|
|
10726
|
+
}
|
|
10727
|
+
|
|
10387
10728
|
function terminalShortcut(e) {
|
|
10388
10729
|
const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
|
|
10389
10730
|
const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
|
|
@@ -10393,13 +10734,11 @@ function terminalShortcut(e) {
|
|
|
10393
10734
|
return () => adjustTerminalFontSize(Math.sign(zoom));
|
|
10394
10735
|
}
|
|
10395
10736
|
if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
|
|
10737
|
+
if (matchKey(e, 'KeyR') && terminalConnectSlow() && wantsTerminal() && !inPageField(e)) return reconnectTerminal;
|
|
10396
10738
|
// Cancelling the default also stops Chrome's system print dialog, which this page has no use for.
|
|
10397
10739
|
if (ctrlShift && e.code === 'KeyP') return toggleSessionPicker;
|
|
10398
10740
|
// Ctrl+Shift+Z is redo in a text field, so only the terminal's own textarea gives it up.
|
|
10399
|
-
if (ctrlShift && e.code === 'KeyZ')
|
|
10400
|
-
const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
|
|
10401
|
-
return field && !document.getElementById('terminal-pane').contains(field) ? null : toggleZenMode;
|
|
10402
|
-
}
|
|
10741
|
+
if (ctrlShift && e.code === 'KeyZ') return inPageField(e) ? null : toggleZenMode;
|
|
10403
10742
|
if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
|
|
10404
10743
|
return () => openNewSession(null, e.code === 'KeyR');
|
|
10405
10744
|
}
|
|
@@ -10493,6 +10832,7 @@ function openSession(sessionId) {
|
|
|
10493
10832
|
const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
|
|
10494
10833
|
if (!alreadyOpen) termState.openedByUser = sessionId;
|
|
10495
10834
|
else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
|
|
10835
|
+
markSessionRead(sessionId);
|
|
10496
10836
|
return fetchTasks(sessionId);
|
|
10497
10837
|
}
|
|
10498
10838
|
|
|
@@ -10595,7 +10935,7 @@ function terminalKeyFilter(e) {
|
|
|
10595
10935
|
return false;
|
|
10596
10936
|
}
|
|
10597
10937
|
if (ctrlOnly && e.code === 'KeyV') return false;
|
|
10598
|
-
return !
|
|
10938
|
+
return !hub.forwards(e);
|
|
10599
10939
|
}
|
|
10600
10940
|
|
|
10601
10941
|
function oscColor(hex) {
|
|
@@ -10765,22 +11105,44 @@ function hideTerminalPrompt() {
|
|
|
10765
11105
|
|
|
10766
11106
|
const TERMINAL_BOOT_VERBS = ['Waking Claude…', 'Clawding…', 'Warming up the terminal…', 'Summoning the session…'];
|
|
10767
11107
|
const TERMINAL_BOOT_MAX_MS = 15000;
|
|
11108
|
+
// The server answers `ready` as soon as the socket attaches, even before Claude draws, so a wait
|
|
11109
|
+
// this long is the connection: under the hub a loopback connect can hang until its timeout.
|
|
11110
|
+
const TERMINAL_SLOW_CONNECT_MS = 1500;
|
|
10768
11111
|
let terminalBootTimer = null;
|
|
11112
|
+
let terminalSlowTimer = null;
|
|
10769
11113
|
|
|
10770
11114
|
// A new PTY shows only a blinking cursor until Claude draws its first frame.
|
|
10771
11115
|
function showTerminalBoot() {
|
|
10772
11116
|
document.getElementById('terminal-boot-verb').textContent = pickRandom(TERMINAL_BOOT_VERBS);
|
|
10773
11117
|
document.getElementById('terminal-boot').classList.add('visible');
|
|
10774
11118
|
clearTimeout(terminalBootTimer);
|
|
10775
|
-
terminalBootTimer = setTimeout(
|
|
11119
|
+
terminalBootTimer = setTimeout(() => {
|
|
11120
|
+
if (!terminalConnectSlow()) hideTerminalBoot();
|
|
11121
|
+
}, TERMINAL_BOOT_MAX_MS);
|
|
10776
11122
|
}
|
|
10777
11123
|
|
|
10778
11124
|
function hideTerminalBoot() {
|
|
10779
11125
|
clearTimeout(terminalBootTimer);
|
|
10780
11126
|
terminalBootTimer = null;
|
|
11127
|
+
clearTerminalSlow();
|
|
10781
11128
|
document.getElementById('terminal-boot').classList.remove('visible');
|
|
10782
11129
|
}
|
|
10783
11130
|
|
|
11131
|
+
function clearTerminalSlow() {
|
|
11132
|
+
clearTimeout(terminalSlowTimer);
|
|
11133
|
+
terminalSlowTimer = null;
|
|
11134
|
+
document.getElementById('terminal-boot').classList.remove('slow');
|
|
11135
|
+
}
|
|
11136
|
+
|
|
11137
|
+
function terminalConnectSlow() {
|
|
11138
|
+
return document.getElementById('terminal-boot').classList.contains('slow');
|
|
11139
|
+
}
|
|
11140
|
+
|
|
11141
|
+
function reconnectTerminal() {
|
|
11142
|
+
const id = termState.sessionId;
|
|
11143
|
+
if (id) openTerminal(id, terminalOpenMode(id));
|
|
11144
|
+
}
|
|
11145
|
+
|
|
10784
11146
|
function showTerminalPrompt(sessionId, title, detail, choices, output = '') {
|
|
10785
11147
|
const el = document.getElementById('terminal-prompt');
|
|
10786
11148
|
const tail = output ? `<pre class="${TINTED_PRE_CLASS} terminal-prompt-output">${escapeHtml(output)}</pre>` : '';
|
|
@@ -10821,13 +11183,41 @@ function syncCloseGuard() {
|
|
|
10821
11183
|
const on = termState.attached && termState.shown;
|
|
10822
11184
|
if (termState.closeGuard === on) return;
|
|
10823
11185
|
termState.closeGuard = on;
|
|
10824
|
-
|
|
11186
|
+
hub.closeGuard(on);
|
|
10825
11187
|
}
|
|
10826
11188
|
|
|
10827
11189
|
window.addEventListener('beforeunload', (e) => {
|
|
10828
|
-
if (termState.closeGuard && !
|
|
11190
|
+
if (termState.closeGuard && !hub.inHub) e.preventDefault();
|
|
10829
11191
|
});
|
|
10830
11192
|
|
|
11193
|
+
// A config-dir switch in the hub reloads this page, and coming back should put the cursor where it was.
|
|
11194
|
+
// The hub's palette takes focus first and clears activeElement here, so the last focus inside this
|
|
11195
|
+
// page is tracked instead; focus leaving the frame keeps it.
|
|
11196
|
+
let terminalHadFocus = false;
|
|
11197
|
+
document.addEventListener('focusin', () => {
|
|
11198
|
+
terminalHadFocus = terminalPaneFocused();
|
|
11199
|
+
});
|
|
11200
|
+
document.addEventListener('focusout', (e) => {
|
|
11201
|
+
if (e.relatedTarget) return;
|
|
11202
|
+
setTimeout(() => {
|
|
11203
|
+
if (document.hasFocus()) terminalHadFocus = terminalPaneFocused();
|
|
11204
|
+
});
|
|
11205
|
+
});
|
|
11206
|
+
window.addEventListener('pagehide', () => {
|
|
11207
|
+
try {
|
|
11208
|
+
if (termState.attached && termState.shown && terminalHadFocus) {
|
|
11209
|
+
sessionStorage.setItem(TERMINAL_FOCUS_KEY, currentSessionId);
|
|
11210
|
+
} else sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
|
|
11211
|
+
} catch (_) {}
|
|
11212
|
+
});
|
|
11213
|
+
|
|
11214
|
+
function restoreTerminalFocus(sessionId) {
|
|
11215
|
+
try {
|
|
11216
|
+
if (sessionStorage.getItem(TERMINAL_FOCUS_KEY) === sessionId) termState.focusNext = true;
|
|
11217
|
+
sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
|
|
11218
|
+
} catch (_) {}
|
|
11219
|
+
}
|
|
11220
|
+
|
|
10831
11221
|
// The terminal stands in for the board, so it takes the board zone however focus arrives
|
|
10832
11222
|
// (Tab, Alt+`, a click); leaveTerminalPane hands it back to the sidebar.
|
|
10833
11223
|
document.getElementById('terminal-pane').addEventListener('focusin', () => {
|
|
@@ -10876,6 +11266,11 @@ async function openTerminal(sessionId, mode, attempt = 0) {
|
|
|
10876
11266
|
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/terminal/ws`);
|
|
10877
11267
|
ws.binaryType = 'arraybuffer';
|
|
10878
11268
|
termState.ws = ws;
|
|
11269
|
+
if (!attempt) {
|
|
11270
|
+
terminalSlowTimer = setTimeout(() => {
|
|
11271
|
+
if (termState.ws === ws) document.getElementById('terminal-boot').classList.add('slow');
|
|
11272
|
+
}, TERMINAL_SLOW_CONNECT_MS);
|
|
11273
|
+
}
|
|
10879
11274
|
const spec = newSpecs.get(sessionId);
|
|
10880
11275
|
let readyAt = 0;
|
|
10881
11276
|
let refused = false;
|
|
@@ -10978,6 +11373,7 @@ function onTerminalMessage(sessionId, msg) {
|
|
|
10978
11373
|
// The server has the prompt now; a later start of the same placeholder must not send it again.
|
|
10979
11374
|
const spec = newSpecs.get(sessionId);
|
|
10980
11375
|
if (spec) spec.prompt = null;
|
|
11376
|
+
clearTerminalSlow();
|
|
10981
11377
|
hideTerminalPrompt();
|
|
10982
11378
|
setTerminalStatus('');
|
|
10983
11379
|
setTerminalAttached(true);
|
|
@@ -11270,13 +11666,15 @@ async function loadTerminals() {
|
|
|
11270
11666
|
const res = await fetch('/api/terminals', { cache: 'no-store' });
|
|
11271
11667
|
const list = (await res.json()).sessions || [];
|
|
11272
11668
|
runningTerminals = new Set(list.map((t) => t.id));
|
|
11669
|
+
renderActivityChip();
|
|
11273
11670
|
return list;
|
|
11274
11671
|
}
|
|
11275
11672
|
|
|
11276
11673
|
function setRunningTerminals(ids) {
|
|
11277
11674
|
runningTerminals = new Set(ids);
|
|
11278
11675
|
if (ids.some((id) => !sessions.some((s) => s.id === id))) fetchSessions(false).catch(() => {});
|
|
11279
|
-
else
|
|
11676
|
+
else renderSessionViews();
|
|
11677
|
+
renderActivityChip();
|
|
11280
11678
|
}
|
|
11281
11679
|
|
|
11282
11680
|
function showSessionTerminal(sessionId) {
|
|
@@ -11637,6 +12035,7 @@ try {
|
|
|
11637
12035
|
loadSessionGroups();
|
|
11638
12036
|
initSessionGroupsDnd();
|
|
11639
12037
|
initSessionPicker();
|
|
12038
|
+
initProjectPicker();
|
|
11640
12039
|
try {
|
|
11641
12040
|
const af = JSON.parse(store.getItem('activityFilter') || '[]');
|
|
11642
12041
|
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
@@ -11828,21 +12227,12 @@ if (urlState.search) {
|
|
|
11828
12227
|
document.getElementById('boot-verb').textContent = pickRandom(verbs);
|
|
11829
12228
|
}
|
|
11830
12229
|
|
|
11831
|
-
|
|
11832
|
-
|
|
11833
|
-
|
|
11834
|
-
|
|
11835
|
-
|
|
11836
|
-
|
|
11837
|
-
})
|
|
11838
|
-
.catch(() => {}),
|
|
11839
|
-
fetch('/api/config')
|
|
11840
|
-
.then((r) => r.json())
|
|
11841
|
-
.then((c) => {
|
|
11842
|
-
appConfig = c;
|
|
11843
|
-
})
|
|
11844
|
-
.catch(() => {}),
|
|
11845
|
-
])
|
|
12230
|
+
fetch('/api/config')
|
|
12231
|
+
.then((r) => r.json())
|
|
12232
|
+
.then((c) => {
|
|
12233
|
+
appConfig = c;
|
|
12234
|
+
})
|
|
12235
|
+
.catch(() => {})
|
|
11846
12236
|
.then(restorePendingSessions)
|
|
11847
12237
|
.then(() => fetchSessions())
|
|
11848
12238
|
.then(async () => {
|
|
@@ -11853,6 +12243,7 @@ Promise.all([
|
|
|
11853
12243
|
showNoSession();
|
|
11854
12244
|
}
|
|
11855
12245
|
} else if (urlState.session) {
|
|
12246
|
+
restoreTerminalFocus(urlState.session);
|
|
11856
12247
|
await fetchTasks(urlState.session);
|
|
11857
12248
|
} else {
|
|
11858
12249
|
const last = loadLastView();
|
|
@@ -11863,6 +12254,7 @@ Promise.all([
|
|
|
11863
12254
|
showNoSession();
|
|
11864
12255
|
}
|
|
11865
12256
|
} else if (last?.view === 'session' && last.session && sessions.some((s) => s.id === last.session)) {
|
|
12257
|
+
restoreTerminalFocus(last.session);
|
|
11866
12258
|
await fetchTasks(last.session);
|
|
11867
12259
|
} else {
|
|
11868
12260
|
showNoSession();
|
|
@@ -11906,49 +12298,65 @@ window.addEventListener('popstate', () => {
|
|
|
11906
12298
|
//#endregion
|
|
11907
12299
|
|
|
11908
12300
|
// #region HUB_INTEGRATION
|
|
11909
|
-
|
|
11910
|
-
|
|
11911
|
-
|
|
11912
|
-
function
|
|
11913
|
-
|
|
11914
|
-
|
|
11915
|
-
|
|
11916
|
-
|
|
11917
|
-
|
|
11918
|
-
|
|
11919
|
-
|
|
11920
|
-
|
|
11921
|
-
|
|
11922
|
-
|
|
11923
|
-
|
|
11924
|
-
|
|
11925
|
-
) {
|
|
11926
|
-
return true;
|
|
11927
|
-
}
|
|
11928
|
-
return e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && (/^[1-9]$/.test(e.key) || /^Digit[1-9]$/.test(e.code));
|
|
11929
|
-
}
|
|
11930
|
-
|
|
11931
|
-
// The combos the hub binds, from its hub:keys message. Null until one arrives: a hub from before
|
|
11932
|
-
// hub:keys sends none, and the fallback filter above is what such a hub expects.
|
|
11933
|
-
let hubKeys = null;
|
|
12301
|
+
const costDetail = (session) => `?view=detail&session=${encodeURIComponent(session)}`;
|
|
12302
|
+
const projectQuery = (project) => `?${new URLSearchParams({ project })}`;
|
|
12303
|
+
const hub = ClaudeHub.connect({
|
|
12304
|
+
// A function, because the --*-url flags arrive with /api/config after connect.
|
|
12305
|
+
standalone: () => ({
|
|
12306
|
+
...(appConfig.costUrl && {
|
|
12307
|
+
'session.cost': (p) => p.session && new URL(costDetail(p.session), appConfig.costUrl).href,
|
|
12308
|
+
}),
|
|
12309
|
+
...(appConfig.marketplaceUrl && {
|
|
12310
|
+
'project.plugins': (p) => p.project && new URL(projectQuery(p.project), appConfig.marketplaceUrl).href,
|
|
12311
|
+
}),
|
|
12312
|
+
...(appConfig.memoryUrl && {
|
|
12313
|
+
'project.memory': (p) => p.project && new URL(projectQuery(p.project), appConfig.memoryUrl).href,
|
|
12314
|
+
}),
|
|
12315
|
+
}),
|
|
12316
|
+
});
|
|
11934
12317
|
|
|
11935
|
-
|
|
11936
|
-
|
|
11937
|
-
|
|
11938
|
-
|
|
11939
|
-
|
|
11940
|
-
|
|
11941
|
-
|
|
12318
|
+
hub.onActive((active) => onHubActive(active));
|
|
12319
|
+
|
|
12320
|
+
// session.changed goes out once the switch settles, so arrowing through the list sends one event.
|
|
12321
|
+
let publishTimer = 0;
|
|
12322
|
+
let publishedSessionId;
|
|
12323
|
+
let cliOpenedId = null;
|
|
12324
|
+
|
|
12325
|
+
function schedulePublishSession() {
|
|
12326
|
+
clearTimeout(publishTimer);
|
|
12327
|
+
publishTimer = setTimeout(publishSession, 150);
|
|
12328
|
+
}
|
|
12329
|
+
|
|
12330
|
+
function publishSession() {
|
|
12331
|
+
if (currentSessionId === publishedSessionId) return;
|
|
12332
|
+
const s = currentSessionId ? sessions.find((x) => x.id === currentSessionId) : null;
|
|
12333
|
+
// A placeholder publishes once its real row replaces it.
|
|
12334
|
+
if (currentSessionId && (!s || s.placeholder)) return;
|
|
12335
|
+
publishedSessionId = currentSessionId;
|
|
12336
|
+
const source = currentSessionId === cliOpenedId ? 'cli' : 'user';
|
|
12337
|
+
cliOpenedId = null;
|
|
12338
|
+
hub.publish(
|
|
12339
|
+
'session.changed',
|
|
12340
|
+
s && {
|
|
12341
|
+
sessionId: s.id,
|
|
12342
|
+
project: s.project,
|
|
12343
|
+
name: s.name || null,
|
|
12344
|
+
gitBranch: s.gitBranch || null,
|
|
12345
|
+
live: isSessionActive(s),
|
|
12346
|
+
source,
|
|
12347
|
+
},
|
|
12348
|
+
);
|
|
11942
12349
|
}
|
|
11943
12350
|
|
|
11944
|
-
|
|
11945
|
-
|
|
12351
|
+
// The help modal stops click propagation, so the document handler below never sees this link.
|
|
12352
|
+
// The service worker can pair a cached page that lacks the link with this script.
|
|
12353
|
+
document.getElementById('help-docs')?.addEventListener('click', (e) => {
|
|
11946
12354
|
e.preventDefault();
|
|
11947
|
-
|
|
12355
|
+
hub.openExternal(e.currentTarget.href);
|
|
11948
12356
|
});
|
|
11949
12357
|
|
|
11950
12358
|
document.addEventListener('click', (e) => {
|
|
11951
|
-
if (!
|
|
12359
|
+
if (!hub.inHub) return;
|
|
11952
12360
|
const a = e.target.closest?.('a[href]');
|
|
11953
12361
|
if (!a) return;
|
|
11954
12362
|
const href = a.getAttribute('href');
|
|
@@ -11963,78 +12371,34 @@ document.addEventListener('click', (e) => {
|
|
|
11963
12371
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
|
|
11964
12372
|
e.preventDefault();
|
|
11965
12373
|
e.stopPropagation();
|
|
11966
|
-
|
|
12374
|
+
hub.openExternal(url.href);
|
|
11967
12375
|
});
|
|
11968
12376
|
|
|
11969
|
-
// The click handler above only catches anchors. A script open goes the same way, because
|
|
11970
|
-
// in the hub's installed PWA window a framed app's own `_blank` open does nothing.
|
|
11971
|
-
function openExternal(url) {
|
|
11972
|
-
if (window.__HUB__?.enabled) hubPost({ type: 'hub:openExternal', url });
|
|
11973
|
-
else window.open(url, '_blank', 'noopener');
|
|
11974
|
-
}
|
|
11975
|
-
|
|
11976
|
-
window.hubNavigate = function hubNavigate(app, url) {
|
|
11977
|
-
if (!window.__HUB__?.enabled) return;
|
|
11978
|
-
hubPost({ type: 'hub:navigate', app, url });
|
|
11979
|
-
};
|
|
11980
|
-
|
|
11981
|
-
// Hoisted out of initHubTheme so initHubProject can share it.
|
|
11982
|
-
const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
|
|
11983
|
-
|
|
11984
|
-
// Every send is addressed to the hub explicitly. With targetOrigin '*' any page that
|
|
11985
|
-
// framed this app also received the forwarded keystrokes and navigation intents.
|
|
11986
|
-
function hubPost(message) {
|
|
11987
|
-
const origin = hubOrigin();
|
|
11988
|
-
if (origin) window.parent?.postMessage(message, origin);
|
|
11989
|
-
}
|
|
11990
|
-
|
|
11991
12377
|
(function initHubTheme() {
|
|
11992
12378
|
const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
|
|
11993
12379
|
const getColorTheme = () => document.body.dataset.colorTheme || 'ember';
|
|
11994
|
-
|
|
11995
|
-
|
|
11996
|
-
|
|
11997
|
-
|
|
11998
|
-
|
|
11999
|
-
|
|
12000
|
-
if (e.data?.type !== 'hub:theme') return;
|
|
12001
|
-
if (typeof e.data.colorTheme === 'string' && e.data.colorTheme !== getColorTheme()) {
|
|
12002
|
-
setColorTheme(e.data.colorTheme);
|
|
12003
|
-
lastColorTheme = getColorTheme();
|
|
12004
|
-
}
|
|
12005
|
-
if (getTheme() !== e.data.theme) {
|
|
12006
|
-
window.toggleTheme();
|
|
12007
|
-
lastTheme = getTheme();
|
|
12008
|
-
}
|
|
12380
|
+
const report = hub.bindTheme({
|
|
12381
|
+
get: () => ({ theme: getTheme(), colorTheme: getColorTheme() }),
|
|
12382
|
+
set: ({ theme, colorTheme }) => {
|
|
12383
|
+
if (colorTheme !== getColorTheme()) setColorTheme(colorTheme);
|
|
12384
|
+
if (theme !== getTheme()) window.toggleTheme();
|
|
12385
|
+
},
|
|
12009
12386
|
});
|
|
12010
|
-
new MutationObserver((
|
|
12011
|
-
const t = getTheme();
|
|
12012
|
-
const ct = getColorTheme();
|
|
12013
|
-
if (t === lastTheme && ct === lastColorTheme) return;
|
|
12014
|
-
lastTheme = t;
|
|
12015
|
-
lastColorTheme = ct;
|
|
12016
|
-
hubPost({ type: 'hub:theme', theme: t, colorTheme: ct });
|
|
12017
|
-
}).observe(document.body, {
|
|
12387
|
+
new MutationObserver(report).observe(document.body, {
|
|
12018
12388
|
attributes: true,
|
|
12019
12389
|
attributeFilter: ['class', 'data-color-theme'],
|
|
12020
12390
|
});
|
|
12021
12391
|
})();
|
|
12022
12392
|
|
|
12023
|
-
(
|
|
12024
|
-
|
|
12025
|
-
|
|
12026
|
-
|
|
12027
|
-
|
|
12028
|
-
});
|
|
12029
|
-
})();
|
|
12393
|
+
// Under the hub the picker lists the hub's themes, which include the user's own (protocol section 6, rule 5).
|
|
12394
|
+
hub.onThemes((themes) => {
|
|
12395
|
+
buildThemeMenu(themes.map((t) => [t.id, t.label]));
|
|
12396
|
+
syncColorThemeSelect(document.body.dataset.colorTheme);
|
|
12397
|
+
});
|
|
12030
12398
|
|
|
12031
|
-
(
|
|
12032
|
-
|
|
12033
|
-
|
|
12034
|
-
|
|
12035
|
-
|
|
12036
|
-
if (typeof dirPath !== 'string' || !dirPath || dirPath === filterProject) return;
|
|
12037
|
-
filterByProject(dirPath);
|
|
12038
|
-
});
|
|
12039
|
-
})();
|
|
12399
|
+
hub.subscribe('project.changed', (p) => {
|
|
12400
|
+
const dirPath = p?.project;
|
|
12401
|
+
if (typeof dirPath !== 'string' || !dirPath || dirPath === filterProject) return;
|
|
12402
|
+
filterByProject(dirPath);
|
|
12403
|
+
});
|
|
12040
12404
|
// #endregion HUB_INTEGRATION
|