claude-code-kanban 4.31.0 → 5.0.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 +683 -373
- package/public/index.html +31 -4
- package/public/style.css +149 -9
- package/public/vendor/claude-hub-sdk.js +50 -0
- package/server.js +89 -28
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);
|
|
@@ -430,25 +450,24 @@ function isSessionLive(s) {
|
|
|
430
450
|
const ACTIVITY_PREDICATES = {
|
|
431
451
|
waiting: isWaitingSession,
|
|
432
452
|
active: isActiveSession,
|
|
453
|
+
terminal: (s) => runningTerminals.has(s.id),
|
|
433
454
|
};
|
|
434
455
|
|
|
435
|
-
let
|
|
456
|
+
let lastChipHtml = '';
|
|
436
457
|
|
|
437
458
|
function renderActivityChip() {
|
|
438
459
|
const container = document.getElementById('activity-chips');
|
|
439
460
|
if (!container) return;
|
|
440
461
|
|
|
441
|
-
|
|
442
|
-
let active = 0;
|
|
462
|
+
const counts = { waiting: 0, active: 0, terminal: 0 };
|
|
443
463
|
for (const s of sessions) {
|
|
444
464
|
if (dismissedSessionIds.has(s.id)) continue;
|
|
445
|
-
if (s
|
|
446
|
-
else if (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents) active++;
|
|
465
|
+
for (const kind in counts) if (ACTIVITY_PREDICATES[kind](s)) counts[kind]++;
|
|
447
466
|
}
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
467
|
+
const { waiting, active, terminal: terminals } = counts;
|
|
468
|
+
const showTerminals = terminalAvailable();
|
|
469
|
+
const maxTerminals = appConfig.terminal?.maxSessions;
|
|
470
|
+
const nearCap = showTerminals && runningTerminals.size >= maxTerminals * 0.8;
|
|
452
471
|
|
|
453
472
|
const chips = [
|
|
454
473
|
{
|
|
@@ -464,14 +483,27 @@ function renderActivityChip() {
|
|
|
464
483
|
title: `${active} session${active === 1 ? '' : 's'} with running work or recent activity`,
|
|
465
484
|
},
|
|
466
485
|
];
|
|
486
|
+
if (showTerminals) {
|
|
487
|
+
chips.push({
|
|
488
|
+
kind: 'terminal',
|
|
489
|
+
count: terminals,
|
|
490
|
+
label: nearCap ? `${runningTerminals.size}/${maxTerminals}` : `${terminals}`,
|
|
491
|
+
title: nearCap
|
|
492
|
+
? `${runningTerminals.size} of ${maxTerminals} terminals open; end some to start more`
|
|
493
|
+
: `${terminals} session${terminals === 1 ? '' : 's'} running in a terminal here`,
|
|
494
|
+
icon: ICON_TERMINAL,
|
|
495
|
+
warn: nearCap,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
467
498
|
|
|
468
|
-
|
|
499
|
+
const html = chips
|
|
469
500
|
.map((c) => {
|
|
470
501
|
const isOn = activityFilter.has(c.kind);
|
|
471
502
|
const classes = [
|
|
472
503
|
'activity-chip',
|
|
473
504
|
`activity-${c.kind}`,
|
|
474
|
-
c.count === 0 ? 'activity-zero' : '',
|
|
505
|
+
c.count === 0 && !c.warn ? 'activity-zero' : '',
|
|
506
|
+
c.warn ? 'activity-warn' : '',
|
|
475
507
|
isOn ? 'activity-filter-on' : '',
|
|
476
508
|
]
|
|
477
509
|
.filter(Boolean)
|
|
@@ -483,12 +515,15 @@ function renderActivityChip() {
|
|
|
483
515
|
onclick="setActivityFilter('${escAttrJs(c.kind)}')"
|
|
484
516
|
aria-pressed="${isOn ? 'true' : 'false'}"
|
|
485
517
|
title="${escapeHtml(c.title + hint)}">
|
|
486
|
-
<span class="activity-dot"></span>
|
|
518
|
+
${c.icon ? `<span class="activity-icon">${c.icon}</span>` : '<span class="activity-dot"></span>'}
|
|
487
519
|
<span class="activity-label">${escapeHtml(c.label)}</span>
|
|
488
520
|
</button>
|
|
489
521
|
`;
|
|
490
522
|
})
|
|
491
523
|
.join('');
|
|
524
|
+
if (html === lastChipHtml) return;
|
|
525
|
+
lastChipHtml = html;
|
|
526
|
+
container.innerHTML = html;
|
|
492
527
|
}
|
|
493
528
|
|
|
494
529
|
function toggleActivityKind(kind) {
|
|
@@ -524,6 +559,7 @@ let lastCurrentTasksHash = '';
|
|
|
524
559
|
|
|
525
560
|
async function fetchTasks(sessionId) {
|
|
526
561
|
try {
|
|
562
|
+
const refresh = viewMode === 'session' && sessionId === currentSessionId;
|
|
527
563
|
viewMode = 'session';
|
|
528
564
|
document.getElementById('message-toggle')?.style.removeProperty('display');
|
|
529
565
|
const res = await fetch(`/api/sessions/${sessionId}`);
|
|
@@ -532,9 +568,7 @@ async function fetchTasks(sessionId) {
|
|
|
532
568
|
const newTasks = await res.json();
|
|
533
569
|
|
|
534
570
|
const hash = JSON.stringify(newTasks);
|
|
535
|
-
if (sessionId === currentSessionId && hash === lastCurrentTasksHash)
|
|
536
|
-
return;
|
|
537
|
-
}
|
|
571
|
+
if (sessionId === currentSessionId && hash === lastCurrentTasksHash) return;
|
|
538
572
|
lastCurrentTasksHash = hash;
|
|
539
573
|
|
|
540
574
|
currentTasks = newTasks;
|
|
@@ -548,18 +582,24 @@ async function fetchTasks(sessionId) {
|
|
|
548
582
|
if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
|
|
549
583
|
revealedStorageSessionId = null;
|
|
550
584
|
}
|
|
551
|
-
if (currentSessionId && currentSessionId !== sessionId
|
|
585
|
+
if (currentSessionId && currentSessionId !== sessionId && deferredPinPlacement.delete(currentSessionId))
|
|
586
|
+
expandPinnedFor(currentSessionId);
|
|
552
587
|
if (lastSessionId !== sessionId) setSwapPair(sessionId, lastSessionId);
|
|
553
588
|
const switched = sessionId !== currentSessionId;
|
|
554
589
|
currentSessionId = sessionId;
|
|
590
|
+
markOpenSessionRead();
|
|
555
591
|
if (switched) autoRevealLog(sessionId, newTasks.length === 0);
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
592
|
+
// A task change in the open session must not reset the messages, the agents or the owner
|
|
593
|
+
// filter: that reset rebuilds the panels and reads as a reload after each card move.
|
|
594
|
+
if (!refresh) {
|
|
595
|
+
currentPins = loadPins(sessionId);
|
|
596
|
+
ownerFilter = '';
|
|
597
|
+
resetMessageScrollState();
|
|
598
|
+
for (const k of Object.keys(ownerColorCache)) delete ownerColorCache[k];
|
|
599
|
+
for (const k of Object.keys(teamColorMap)) delete teamColorMap[k];
|
|
600
|
+
sessionJustSelected = true;
|
|
601
|
+
resetAgentState();
|
|
602
|
+
}
|
|
563
603
|
updateUrl();
|
|
564
604
|
renderSession();
|
|
565
605
|
renderSessions();
|
|
@@ -1913,6 +1953,7 @@ function toggleSessionPin(sessionId) {
|
|
|
1913
1953
|
} else {
|
|
1914
1954
|
pinnedSessionIds.add(sessionId);
|
|
1915
1955
|
if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
|
|
1956
|
+
expandPinnedFor(sessionId);
|
|
1916
1957
|
}
|
|
1917
1958
|
savePinnedSessions();
|
|
1918
1959
|
offloadSessionPin(sessionId);
|
|
@@ -1951,6 +1992,7 @@ function handleSessionPinEvent({ id, state }) {
|
|
|
1951
1992
|
pinnedSessionIds.add(id);
|
|
1952
1993
|
stickySessionIds.add(id);
|
|
1953
1994
|
}
|
|
1995
|
+
expandPinnedFor(id);
|
|
1954
1996
|
savePinnedSessions();
|
|
1955
1997
|
renderSessions();
|
|
1956
1998
|
}
|
|
@@ -2411,6 +2453,10 @@ function showToast(msg, type, action) {
|
|
|
2411
2453
|
_toastTimer = setTimeout(hide, action ? 8000 : 2000);
|
|
2412
2454
|
}
|
|
2413
2455
|
|
|
2456
|
+
function sendMessageAddress(peerName) {
|
|
2457
|
+
return `SendMessage(${peerName})`;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2414
2460
|
async function copyWithFeedback(text, btn) {
|
|
2415
2461
|
if (btn.dataset.copying) return;
|
|
2416
2462
|
try {
|
|
@@ -3296,7 +3342,7 @@ function getFilteredSessions() {
|
|
|
3296
3342
|
// Skip pinned sessions — they are prepended separately below (lines ~2180) to preserve stable position.
|
|
3297
3343
|
const filteredIds = new Set(filteredSessions.map((s) => s.id));
|
|
3298
3344
|
for (const id of [revealedPlanSessionId, revealedStorageSessionId, currentSessionId]) {
|
|
3299
|
-
if (id && !filteredIds.has(id) && !isAnyPinned(id)) {
|
|
3345
|
+
if (id && !filteredIds.has(id) && !(isAnyPinned(id) && activityFilter.size === 0)) {
|
|
3300
3346
|
const session = sessions.find((s) => s.id === id);
|
|
3301
3347
|
if (session) {
|
|
3302
3348
|
const insertAt = filteredSessions.findIndex((s) => s.modifiedAt < session.modifiedAt);
|
|
@@ -3315,7 +3361,9 @@ function getFilteredSessions() {
|
|
|
3315
3361
|
|
|
3316
3362
|
if (activityFilter.size > 0) {
|
|
3317
3363
|
const preds = [...activityFilter].map((k) => ACTIVITY_PREDICATES[k]).filter(Boolean);
|
|
3318
|
-
if (preds.length)
|
|
3364
|
+
if (preds.length) {
|
|
3365
|
+
filteredSessions = filteredSessions.filter((s) => s.id === currentSessionId || preds.some((p) => p(s)));
|
|
3366
|
+
}
|
|
3319
3367
|
}
|
|
3320
3368
|
|
|
3321
3369
|
if (searchQuery) {
|
|
@@ -3446,9 +3494,14 @@ function renderSessions() {
|
|
|
3446
3494
|
const bookmarksCount = loadPins(session.id).length;
|
|
3447
3495
|
const hasScratchpad = _hasScratchpad(_sessionScratchpadKey(session.id));
|
|
3448
3496
|
const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
|
|
3497
|
+
// Open tasks keep an idle session warm, but the green dot is for a session that is working.
|
|
3498
|
+
const idleClass =
|
|
3499
|
+
tempClass === 'warm' && !session.hasWaitingForUser && !session.hasRecentLog && !session.hasRunningAgents
|
|
3500
|
+
? 'idle'
|
|
3501
|
+
: '';
|
|
3449
3502
|
const sid = escAttrJs(session.id);
|
|
3450
3503
|
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)}">
|
|
3504
|
+
<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} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
|
|
3452
3505
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
3453
3506
|
<div class="session-name">${escapeHtml(sessionName)}</div>
|
|
3454
3507
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
@@ -3506,12 +3559,13 @@ function renderSessions() {
|
|
|
3506
3559
|
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
3560
|
};
|
|
3508
3561
|
// `ungroup` marks the label as the drop target for pulling an item out of a named group.
|
|
3509
|
-
const sectionHtml = (key, text, ungroup, body) => {
|
|
3562
|
+
const sectionHtml = (key, text, ungroup, body, empty = false) => {
|
|
3510
3563
|
const collapsed = collapsedProjectGroups.has(key);
|
|
3511
3564
|
const escPath = escapeHtml(key);
|
|
3512
3565
|
const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
|
|
3513
|
-
|
|
3514
|
-
|
|
3566
|
+
const emptyCls = empty ? ' sg-section-empty' : '';
|
|
3567
|
+
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>
|
|
3568
|
+
<div class="sg-section-body${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}">${body}</div>`;
|
|
3515
3569
|
};
|
|
3516
3570
|
|
|
3517
3571
|
const renderGroupSessions = (sessions, pinKey) => {
|
|
@@ -3521,7 +3575,7 @@ function renderSessions() {
|
|
|
3521
3575
|
const gIdlePinned = gPinned.filter((s) => !isSessionActive(s));
|
|
3522
3576
|
const gUnpinned = sessions.filter((s) => !isPlacedPinned(s.id) || isSessionActive(s) || isPlacedSticky(s.id));
|
|
3523
3577
|
const pinCollapsed = collapsedProjectGroups.has(pinKey);
|
|
3524
|
-
if (gIdlePinned.length === 0
|
|
3578
|
+
if (gIdlePinned.length === 0) return gUnpinned.map(renderSessionCard).join('');
|
|
3525
3579
|
return (
|
|
3526
3580
|
'<div class="pinned-sub-section">' +
|
|
3527
3581
|
'<div class="pinned-sub-header' +
|
|
@@ -3732,14 +3786,15 @@ function renderSessions() {
|
|
|
3732
3786
|
}
|
|
3733
3787
|
}
|
|
3734
3788
|
|
|
3735
|
-
|
|
3789
|
+
const noProjects = sortedGroups.length === 0 && ungrouped.length === 0;
|
|
3790
|
+
html += sectioned ? sectionHtml(SECTION_PROJECTS, 'Projects', true, projectsHtml, noProjects) : projectsHtml;
|
|
3736
3791
|
|
|
3737
3792
|
sessionsList.innerHTML = html;
|
|
3738
3793
|
} else {
|
|
3739
3794
|
const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
|
|
3740
3795
|
const restHtml =
|
|
3741
3796
|
sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
|
|
3742
|
-
const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml) : restHtml;
|
|
3797
|
+
const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
|
|
3743
3798
|
sessionsList.innerHTML = html + sgSectionHtml(true) + tail;
|
|
3744
3799
|
}
|
|
3745
3800
|
|
|
@@ -3788,8 +3843,7 @@ function renderSession() {
|
|
|
3788
3843
|
// happened to open the info modal or zen panel.
|
|
3789
3844
|
loadSessionPads(currentSessionId);
|
|
3790
3845
|
|
|
3791
|
-
const displayName =
|
|
3792
|
-
session.customTitle || session.name || session.gitBranch || session.description || currentSessionId;
|
|
3846
|
+
const displayName = session.name || session.description || currentSessionId;
|
|
3793
3847
|
|
|
3794
3848
|
sessionTitle.textContent = displayName;
|
|
3795
3849
|
|
|
@@ -3842,8 +3896,17 @@ function renderProjectView() {
|
|
|
3842
3896
|
renderSessions();
|
|
3843
3897
|
}
|
|
3844
3898
|
|
|
3899
|
+
// Claude Code keeps a blocker's id in blockedBy after the blocker completes.
|
|
3900
|
+
function openBlockers(task) {
|
|
3901
|
+
const list = (t) => t.sessionId || t._taskDir;
|
|
3902
|
+
return (task.blockedBy || []).filter(
|
|
3903
|
+
(id) => currentTasks.find((t) => t.id === id && list(t) === list(task))?.status !== 'completed',
|
|
3904
|
+
);
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3845
3907
|
function renderTaskCard(task) {
|
|
3846
|
-
const
|
|
3908
|
+
const blockers = openBlockers(task);
|
|
3909
|
+
const isBlocked = blockers.length > 0;
|
|
3847
3910
|
const useSlug = viewMode === 'project';
|
|
3848
3911
|
const taskId = useSlug ? `${(task._taskDir || task.sessionId || '')?.slice(0, 4)}-${task.id}` : task.id;
|
|
3849
3912
|
const statusClass = task.status.replace('_', '-');
|
|
@@ -3877,7 +3940,7 @@ function renderTaskCard(task) {
|
|
|
3877
3940
|
</div>
|
|
3878
3941
|
<div class="task-title">${escapeHtml(task.subject)}</div>
|
|
3879
3942
|
${task.status === 'in_progress' && task.activeForm ? `<div class="task-active">${escapeHtml(task.activeForm)}</div>` : ''}
|
|
3880
|
-
${isBlocked ? `<div class="task-blocked">Waiting on ${
|
|
3943
|
+
${isBlocked ? `<div class="task-blocked">Waiting on ${blockers.map((id) => `#${id}`).join(', ')}</div>` : ''}
|
|
3881
3944
|
${task.description ? `<div class="task-desc">${escapeHtml(task.description.split('\n')[0])}</div>` : ''}
|
|
3882
3945
|
</div>
|
|
3883
3946
|
`;
|
|
@@ -3960,7 +4023,7 @@ function renderKanban() {
|
|
|
3960
4023
|
// away mid-typing -- leave the column alone until the input is gone.
|
|
3961
4024
|
if (!addingTask) {
|
|
3962
4025
|
const addTile = canAddTask()
|
|
3963
|
-
? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this)">${plusIcon}<span>Add task</span></button>`
|
|
4026
|
+
? `<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
4027
|
: '';
|
|
3965
4028
|
writes.push([
|
|
3966
4029
|
pendingTasks,
|
|
@@ -4143,17 +4206,21 @@ async function onColumnDrop(e) {
|
|
|
4143
4206
|
(t) => t.id === taskId && (t._taskDir === sessionId || (t.sessionId || currentSessionId) === sessionId),
|
|
4144
4207
|
);
|
|
4145
4208
|
if (!task || task.status === newStatus) return;
|
|
4209
|
+
const oldStatus = task.status;
|
|
4210
|
+
task.status = newStatus;
|
|
4211
|
+
renderKanban();
|
|
4146
4212
|
try {
|
|
4147
4213
|
const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
|
|
4148
4214
|
method: 'PUT',
|
|
4149
4215
|
headers: { 'Content-Type': 'application/json' },
|
|
4150
4216
|
body: JSON.stringify({ status: newStatus }),
|
|
4151
4217
|
});
|
|
4152
|
-
if (res.ok)
|
|
4153
|
-
task.status = newStatus;
|
|
4154
|
-
renderKanban();
|
|
4155
|
-
}
|
|
4218
|
+
if (res.ok) return;
|
|
4156
4219
|
} catch (_) {}
|
|
4220
|
+
if (task.status === newStatus) {
|
|
4221
|
+
task.status = oldStatus;
|
|
4222
|
+
renderKanban();
|
|
4223
|
+
}
|
|
4157
4224
|
}
|
|
4158
4225
|
|
|
4159
4226
|
//#endregion
|
|
@@ -4899,6 +4966,7 @@ function getNavigableItems() {
|
|
|
4899
4966
|
// into the body to reach the blocks inside.
|
|
4900
4967
|
const walkTopLevel = (children) => {
|
|
4901
4968
|
for (const el of children) {
|
|
4969
|
+
if (el.classList.contains('sg-section-empty')) continue;
|
|
4902
4970
|
if (
|
|
4903
4971
|
el.classList.contains('project-group-header') ||
|
|
4904
4972
|
el.classList.contains('session-group-header') ||
|
|
@@ -5007,14 +5075,11 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
5007
5075
|
// it (pinned, not sticky, idle — see renderGroupSessions), and — since which section holds it
|
|
5008
5076
|
// depends on the view — every section header. Returns whether anything changed.
|
|
5009
5077
|
function uncollapseFor(session) {
|
|
5010
|
-
const project = session.project || '__ungrouped__';
|
|
5011
5078
|
const group = sgGroupForSession(session);
|
|
5012
|
-
const inPinned = isPlacedPinned(session.id) && !isPlacedSticky(session.id) && !isSessionActive(session);
|
|
5013
5079
|
const keys = [
|
|
5014
|
-
project,
|
|
5015
|
-
|
|
5080
|
+
session.project || '__ungrouped__',
|
|
5081
|
+
...pinnedCollapseKeys(session),
|
|
5016
5082
|
group && sgKey(group.id),
|
|
5017
|
-
group && inPinned && `__pinned_group_${group.id}__`,
|
|
5018
5083
|
SECTION_GROUPS,
|
|
5019
5084
|
SECTION_PROJECTS,
|
|
5020
5085
|
SECTION_SESSIONS,
|
|
@@ -5026,6 +5091,24 @@ function uncollapseFor(session) {
|
|
|
5026
5091
|
return changed;
|
|
5027
5092
|
}
|
|
5028
5093
|
|
|
5094
|
+
function pinnedCollapseKeys(session) {
|
|
5095
|
+
if (!isPlacedPinned(session.id) || isPlacedSticky(session.id) || isSessionActive(session)) return [];
|
|
5096
|
+
const group = sgGroupForSession(session);
|
|
5097
|
+
const host = sgHostOf(group, session);
|
|
5098
|
+
return [pinKey(session.project || '__ungrouped__'), host && pinKey(host), group && `__pinned_group_${group.id}__`];
|
|
5099
|
+
}
|
|
5100
|
+
|
|
5101
|
+
// A card that moves into a collapsed Pinned sub-section would vanish from under the click.
|
|
5102
|
+
function expandPinnedFor(sessionId) {
|
|
5103
|
+
const session = sessions.find((s) => s.id === sessionId);
|
|
5104
|
+
if (!session) return;
|
|
5105
|
+
let changed = false;
|
|
5106
|
+
for (const key of pinnedCollapseKeys(session)) {
|
|
5107
|
+
if (key && collapsedProjectGroups.delete(key)) changed = true;
|
|
5108
|
+
}
|
|
5109
|
+
if (changed) persistCollapsedGroups();
|
|
5110
|
+
}
|
|
5111
|
+
|
|
5029
5112
|
function isGroupHeader(el) {
|
|
5030
5113
|
return el.matches(COLLAPSIBLE_HEADER_SELECTOR);
|
|
5031
5114
|
}
|
|
@@ -5088,7 +5171,7 @@ function activateSelectedSession(items) {
|
|
|
5088
5171
|
}
|
|
5089
5172
|
}
|
|
5090
5173
|
|
|
5091
|
-
function setFocusZone(zone) {
|
|
5174
|
+
function setFocusZone(zone, target) {
|
|
5092
5175
|
clearKbSelection();
|
|
5093
5176
|
clearTaskSelection();
|
|
5094
5177
|
|
|
@@ -5097,8 +5180,11 @@ function setFocusZone(zone) {
|
|
|
5097
5180
|
expandSidebar();
|
|
5098
5181
|
const items = getNavigableItems();
|
|
5099
5182
|
if (items.length > 0) {
|
|
5183
|
+
const targetIdx = target ? items.indexOf(target) : -1;
|
|
5100
5184
|
const activeIdx = items.findIndex((el) => el.classList.contains('active'));
|
|
5101
|
-
if (
|
|
5185
|
+
if (targetIdx >= 0) {
|
|
5186
|
+
selectSessionByIndex(targetIdx, items);
|
|
5187
|
+
} else if (activeIdx >= 0) {
|
|
5102
5188
|
selectSessionByIndex(activeIdx);
|
|
5103
5189
|
} else if (selectedSessionKbId) {
|
|
5104
5190
|
const restoredIdx = items.findIndex((el) => getKbId(el) === selectedSessionKbId);
|
|
@@ -5129,6 +5215,18 @@ function setFocusZone(zone) {
|
|
|
5129
5215
|
}
|
|
5130
5216
|
}
|
|
5131
5217
|
|
|
5218
|
+
// A pointer click puts the keyboard cursor on the row, unless a task card or the terminal holds it.
|
|
5219
|
+
// Enter's el.click() arrives with detail 0 and already has the cursor.
|
|
5220
|
+
sessionsList.addEventListener('click', (e) => {
|
|
5221
|
+
if (e.detail === 0 || e.target.closest(SG_ACTION_SELECTOR)) return;
|
|
5222
|
+
const el = e.target.closest(
|
|
5223
|
+
'.session-item, .project-group-header, .session-group-header, .pinned-sub-header, .sg-section-toggle',
|
|
5224
|
+
);
|
|
5225
|
+
if (!el) return;
|
|
5226
|
+
const taskInUse = selectedTaskId && selectedSessionId === (el.dataset.sessionId || currentSessionId);
|
|
5227
|
+
if (focusZone === 'sidebar' || (!taskInUse && !terminalPaneFocused())) setFocusZone('sidebar', el);
|
|
5228
|
+
});
|
|
5229
|
+
|
|
5132
5230
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5133
5231
|
function getAvailableTasksOptions(currentTaskId = null) {
|
|
5134
5232
|
const pending = currentTasks.filter((t) => t.status === 'pending' && t.id !== currentTaskId);
|
|
@@ -5198,7 +5296,8 @@ async function showTaskDetail(taskId, sessionId = null) {
|
|
|
5198
5296
|
pending: '<span class="detail-status pending"><span class="dot"></span>Pending</span>',
|
|
5199
5297
|
};
|
|
5200
5298
|
|
|
5201
|
-
const
|
|
5299
|
+
const blockers = openBlockers(task);
|
|
5300
|
+
const isBlocked = blockers.length > 0;
|
|
5202
5301
|
const actualSessionId = task.sessionId || sessionId || currentSessionId;
|
|
5203
5302
|
|
|
5204
5303
|
detailContent.innerHTML = `
|
|
@@ -5231,12 +5330,12 @@ async function showTaskDetail(taskId, sessionId = null) {
|
|
|
5231
5330
|
}
|
|
5232
5331
|
|
|
5233
5332
|
${
|
|
5234
|
-
|
|
5333
|
+
isBlocked
|
|
5235
5334
|
? `
|
|
5236
5335
|
<div class="detail-section">
|
|
5237
5336
|
<div class="detail-label">Blocked By</div>
|
|
5238
5337
|
<div class="detail-deps">
|
|
5239
|
-
<div class="detail-box blocked"><strong>Blocked by:</strong> ${
|
|
5338
|
+
<div class="detail-box blocked"><strong>Blocked by:</strong> ${blockers.map((id) => `#${id}`).join(', ')}</div>
|
|
5240
5339
|
</div>
|
|
5241
5340
|
</div>`
|
|
5242
5341
|
: ''
|
|
@@ -5495,147 +5594,219 @@ async function confirmDelete() {
|
|
|
5495
5594
|
//#endregion
|
|
5496
5595
|
|
|
5497
5596
|
//#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
|
-
|
|
5597
|
+
// Each tab shows its groups two to a row; a pair shares grid rows, so the two headings sit level and
|
|
5598
|
+
// the shorter group leaves empty rows. `combo` joins the keys with a plus (a chord) instead of
|
|
5599
|
+
// listing them as alternatives. The `hub` tab only works inside Claude Code Hub.
|
|
5600
|
+
const SHORTCUT_TABS = [
|
|
5601
|
+
{
|
|
5602
|
+
title: 'Board',
|
|
5603
|
+
groups: [
|
|
5604
|
+
{
|
|
5605
|
+
title: 'Navigate',
|
|
5606
|
+
rows: [
|
|
5607
|
+
{ keys: ['↓'], label: 'Next item' },
|
|
5608
|
+
{ keys: ['↑'], label: 'Previous item' },
|
|
5609
|
+
{ keys: ['←'], label: 'Left column / collapse group' },
|
|
5610
|
+
{ keys: ['→'], label: 'Right column / expand group' },
|
|
5611
|
+
{ keys: ['Tab'], label: 'Switch sidebar ↔ board' },
|
|
5612
|
+
{ keys: ['Enter', 'Space'], label: 'Open selected item' },
|
|
5613
|
+
],
|
|
5614
|
+
},
|
|
5615
|
+
{
|
|
5616
|
+
title: 'Board',
|
|
5617
|
+
rows: [
|
|
5618
|
+
{ keys: ['Enter'], label: 'Toggle task detail panel' },
|
|
5619
|
+
{ keys: ['D'], label: 'Delete selected task' },
|
|
5620
|
+
{ keys: ['N'], label: 'Toggle scratchpad (in the sidebar: the item under the cursor)' },
|
|
5621
|
+
{ keys: ['R'], label: 'Refresh data' },
|
|
5622
|
+
{ keys: ['Esc'], label: 'Close panel / clear selection' },
|
|
5623
|
+
],
|
|
5624
|
+
},
|
|
5625
|
+
{
|
|
5626
|
+
title: 'View',
|
|
5627
|
+
rows: [
|
|
5628
|
+
{ keys: ['['], label: 'Toggle sidebar' },
|
|
5629
|
+
{ keys: ['T'], label: 'Toggle theme' },
|
|
5630
|
+
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
5631
|
+
{ keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
5632
|
+
{ keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
|
|
5633
|
+
{ keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
|
|
5634
|
+
{ keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
|
|
5635
|
+
{ keys: ['?'], label: 'Show this help' },
|
|
5636
|
+
],
|
|
5637
|
+
},
|
|
5638
|
+
{
|
|
5639
|
+
title: 'Copy',
|
|
5640
|
+
rows: [
|
|
5641
|
+
{ keys: ['Shift', 'C'], combo: true, label: 'Session id' },
|
|
5642
|
+
{ keys: ['Ctrl', 'Shift', 'C'], combo: true, label: 'Session name (SendMessage address)' },
|
|
5643
|
+
],
|
|
5644
|
+
},
|
|
5645
|
+
],
|
|
5646
|
+
},
|
|
5647
|
+
{
|
|
5648
|
+
title: 'Session',
|
|
5649
|
+
groups: [
|
|
5650
|
+
{
|
|
5651
|
+
title: 'Session',
|
|
5652
|
+
rows: [
|
|
5653
|
+
{ keys: ['P'], label: 'Open plan' },
|
|
5654
|
+
{ keys: ['Ctrl', 'Shift', 'P'], combo: true, label: 'Session picker' },
|
|
5655
|
+
{ keys: ['Shift', 'P'], combo: true, label: 'Project picker (this board)' },
|
|
5656
|
+
{ keys: ['I'], label: 'Session info' },
|
|
5657
|
+
{ keys: ['.'], label: 'Pin / unpin' },
|
|
5658
|
+
{ keys: ['>'], label: 'Toggle sticky' },
|
|
5659
|
+
{ keys: ['Shift', 'F10'], combo: true, label: 'Move to group (or Menu key)' },
|
|
5660
|
+
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session' },
|
|
5661
|
+
{ keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
5662
|
+
{ keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
|
|
5663
|
+
{ keys: ['↑', '↓'], label: 'Previous / next message in detail' },
|
|
5664
|
+
{ keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt' },
|
|
5665
|
+
],
|
|
5666
|
+
},
|
|
5667
|
+
{
|
|
5668
|
+
title: 'Sessions',
|
|
5669
|
+
rows: [
|
|
5670
|
+
{ keys: ['Ctrl', 'Alt', 'N'], combo: true, label: 'New session' },
|
|
5671
|
+
{ keys: ['Ctrl', 'Alt', 'R'], combo: true, label: 'Resume session (claude -r)' },
|
|
5672
|
+
{ keys: ['Ctrl', 'Alt', 'S'], combo: true, label: 'Swap to previous session' },
|
|
5673
|
+
],
|
|
5674
|
+
},
|
|
5675
|
+
],
|
|
5676
|
+
},
|
|
5677
|
+
{
|
|
5678
|
+
title: 'Terminal',
|
|
5679
|
+
groups: [
|
|
5680
|
+
{
|
|
5681
|
+
title: 'Terminal',
|
|
5682
|
+
rows: [
|
|
5683
|
+
{ keys: ['Ctrl', '`'], combo: true, label: 'Show / hide terminal' },
|
|
5684
|
+
{ keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
|
|
5685
|
+
{ keys: ['Alt', 'Shift', '`'], combo: true, label: 'End and close terminal' },
|
|
5686
|
+
{ keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
|
|
5687
|
+
{ keys: ['Ctrl', '+/−/0'], combo: true, label: 'Terminal text size' },
|
|
5688
|
+
],
|
|
5689
|
+
},
|
|
5690
|
+
],
|
|
5691
|
+
},
|
|
5692
|
+
{
|
|
5693
|
+
title: 'Hub',
|
|
5694
|
+
hub: true,
|
|
5695
|
+
groups: [
|
|
5696
|
+
{
|
|
5697
|
+
title: 'Hub',
|
|
5698
|
+
rows: [
|
|
5699
|
+
{ keys: ['M'], label: 'Jump to marketplace' },
|
|
5700
|
+
{ keys: ['$'], label: 'Jump to cost' },
|
|
5701
|
+
{ keys: ['Ctrl', 'M'], combo: true, label: 'Jump to memory' },
|
|
5702
|
+
{ keys: ['Ctrl', 'Alt', '←/→'], combo: true, label: 'Previous / next hub app' },
|
|
5703
|
+
{ keys: ['Alt', '1…9'], combo: true, label: 'Jump to hub app by number' },
|
|
5704
|
+
{ keys: ['Ctrl', 'Alt', 'P'], combo: true, label: 'Project picker' },
|
|
5705
|
+
{ keys: ['Ctrl', 'Alt', 'W'], combo: true, label: 'Config dir picker' },
|
|
5706
|
+
{ keys: ['Ctrl', 'Alt', 'A'], combo: true, label: 'App launcher' },
|
|
5707
|
+
],
|
|
5708
|
+
},
|
|
5709
|
+
],
|
|
5710
|
+
},
|
|
5598
5711
|
];
|
|
5599
5712
|
|
|
5600
|
-
const EMPTY_GROUP = { title: '', rows: [] };
|
|
5601
|
-
|
|
5602
5713
|
// Interleaves each pair's rows left-then-right so CSS grid auto-placement lands
|
|
5603
5714
|
// them on shared row tracks (see .shortcuts in style.css).
|
|
5604
|
-
function buildHelpShortcuts() {
|
|
5715
|
+
function buildHelpShortcuts(groups) {
|
|
5605
5716
|
const cells = [];
|
|
5606
|
-
|
|
5717
|
+
for (let pair = 0; pair < groups.length; pair += 2) {
|
|
5607
5718
|
const first = pair === 0 ? ' sc-first' : '';
|
|
5608
5719
|
const sides = [
|
|
5609
|
-
[
|
|
5610
|
-
[
|
|
5611
|
-
];
|
|
5612
|
-
const
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
for (let i = 0; i < Math.max(left.rows.length, right.rows.length); i++) {
|
|
5720
|
+
[groups[pair], 'sc-l'],
|
|
5721
|
+
[groups[pair + 1], 'sc-r'],
|
|
5722
|
+
].filter(([g]) => g);
|
|
5723
|
+
for (const [g, side] of sides)
|
|
5724
|
+
cells.push(`<div class="${escapeHtml(`sc-group ${side}${first}`)}">${escapeHtml(g.title)}</div>`);
|
|
5725
|
+
for (let i = 0; i < Math.max(...sides.map(([g]) => g.rows.length)); i++) {
|
|
5616
5726
|
for (const [group, side] of sides) {
|
|
5617
5727
|
const row = group.rows[i];
|
|
5618
5728
|
if (!row) continue;
|
|
5619
5729
|
const sep = row.combo ? '<span class="sc-plus">+</span>' : '<span class="sc-or">/</span>';
|
|
5620
5730
|
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
|
-
);
|
|
5731
|
+
const label = escapeHtml(row.label);
|
|
5732
|
+
cells.push(`<dt class="${escapeHtml(side)}">${keys}</dt><dd class="${escapeHtml(side)}">${label}</dd>`);
|
|
5625
5733
|
}
|
|
5626
5734
|
}
|
|
5627
|
-
}
|
|
5735
|
+
}
|
|
5628
5736
|
return cells.join('');
|
|
5629
5737
|
}
|
|
5630
5738
|
|
|
5739
|
+
let helpTab = 0;
|
|
5740
|
+
const helpFilter = document.getElementById('help-filter');
|
|
5741
|
+
|
|
5742
|
+
// Hub keys do nothing outside Claude Code Hub, so the standalone board drops that tab.
|
|
5743
|
+
const helpTabs = () => SHORTCUT_TABS.filter((t) => hub.inHub || !t.hub);
|
|
5744
|
+
|
|
5745
|
+
// The matching rows of every tab, in their groups.
|
|
5746
|
+
function filteredShortcutGroups(query) {
|
|
5747
|
+
return helpTabs()
|
|
5748
|
+
.flatMap((t) => t.groups)
|
|
5749
|
+
.map((g) => ({ title: g.title, rows: g.rows.filter((r) => fuzzyMatch(`${r.label} ${r.keys.join(' ')}`, query)) }))
|
|
5750
|
+
.filter((g) => g.rows.length);
|
|
5751
|
+
}
|
|
5752
|
+
|
|
5753
|
+
function renderHelp() {
|
|
5754
|
+
const tabs = helpTabs();
|
|
5755
|
+
helpTab = Math.min(helpTab, tabs.length - 1);
|
|
5756
|
+
const query = helpFilter.value.trim();
|
|
5757
|
+
document.getElementById('help-tabs').innerHTML = tabs
|
|
5758
|
+
.map((t, i) => {
|
|
5759
|
+
const on = !query && i === helpTab;
|
|
5760
|
+
return `<button type="button" role="tab" class="help-tab${on ? ' active' : ''}" aria-selected="${escapeHtml(String(on))}" data-idx="${i}">${escapeHtml(t.title)}</button>`;
|
|
5761
|
+
})
|
|
5762
|
+
.join('');
|
|
5763
|
+
const groups = query ? filteredShortcutGroups(query) : tabs[helpTab].groups;
|
|
5764
|
+
document.getElementById('help-shortcuts').innerHTML = groups.length
|
|
5765
|
+
? buildHelpShortcuts(groups)
|
|
5766
|
+
: `<div class="sc-empty">No shortcut matches "${escapeHtml(query)}"</div>`;
|
|
5767
|
+
}
|
|
5768
|
+
|
|
5769
|
+
function clearHelpFilter() {
|
|
5770
|
+
helpFilter.value = '';
|
|
5771
|
+
renderHelp();
|
|
5772
|
+
}
|
|
5773
|
+
|
|
5774
|
+
function selectHelpTab(idx) {
|
|
5775
|
+
helpTab = idx;
|
|
5776
|
+
clearHelpFilter();
|
|
5777
|
+
}
|
|
5778
|
+
|
|
5631
5779
|
function showHelpModal() {
|
|
5632
|
-
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
list.classList.toggle('sc-standalone', !window.__HUB__?.enabled);
|
|
5636
|
-
modal.classList.add('visible');
|
|
5780
|
+
clearHelpFilter();
|
|
5781
|
+
document.getElementById('help-modal').classList.add('visible');
|
|
5782
|
+
helpFilter.focus();
|
|
5637
5783
|
}
|
|
5638
5784
|
|
|
5785
|
+
document.getElementById('help-tabs').addEventListener('click', (e) => {
|
|
5786
|
+
const tab = e.target.closest('[data-idx]');
|
|
5787
|
+
if (!tab) return;
|
|
5788
|
+
selectHelpTab(Number(tab.dataset.idx));
|
|
5789
|
+
helpFilter.focus();
|
|
5790
|
+
});
|
|
5791
|
+
|
|
5792
|
+
helpFilter.addEventListener('input', renderHelp);
|
|
5793
|
+
|
|
5794
|
+
// The page's key handler skips text fields, so the filter handles its own Esc and tab keys.
|
|
5795
|
+
helpFilter.addEventListener('keydown', (e) => {
|
|
5796
|
+
if (e.key === 'Escape') {
|
|
5797
|
+
e.preventDefault();
|
|
5798
|
+
e.stopPropagation();
|
|
5799
|
+
if (helpFilter.value) clearHelpFilter();
|
|
5800
|
+
else closeHelpModal();
|
|
5801
|
+
return;
|
|
5802
|
+
}
|
|
5803
|
+
const step = matchKey(e, 'ArrowLeft') ? -1 : matchKey(e, 'ArrowRight') ? 1 : 0;
|
|
5804
|
+
if (!step || helpFilter.value) return;
|
|
5805
|
+
e.preventDefault();
|
|
5806
|
+
const n = helpTabs().length;
|
|
5807
|
+
selectHelpTab((helpTab + step + n) % n);
|
|
5808
|
+
});
|
|
5809
|
+
|
|
5639
5810
|
function closeHelpModal() {
|
|
5640
5811
|
const modal = document.getElementById('help-modal');
|
|
5641
5812
|
modal.classList.remove('visible');
|
|
@@ -6134,7 +6305,7 @@ function _renderStorageLinkedDocs() {
|
|
|
6134
6305
|
<span class="storage-item-id" title="${escapeHtml(p)}">${escapeHtml(name)}</span>
|
|
6135
6306
|
<div class="storage-item-actions">
|
|
6136
6307
|
<button onclick="_storagePreviewLinkedDoc('${jsPath}')">View</button>
|
|
6137
|
-
<button onclick="copyWithFeedback('${jsPath}', this)" title="Copy ${what}" aria-label="Copy ${what}">${ICON_COPY}</button>
|
|
6308
|
+
<button onclick="copyWithFeedback('${jsPath}', this)" title="Copy ${escapeHtml(what)}" aria-label="Copy ${escapeHtml(what)}">${ICON_COPY}</button>
|
|
6138
6309
|
<button class="danger" onclick="_storageUnlinkDoc('${sid}','${jsPath}')">Unlink</button>
|
|
6139
6310
|
</div>
|
|
6140
6311
|
</div>`;
|
|
@@ -6275,6 +6446,7 @@ const MODAL_CLOSERS = {
|
|
|
6275
6446
|
'agent-modal': () => closeAgentModal(),
|
|
6276
6447
|
'help-modal': () => closeHelpModal(),
|
|
6277
6448
|
'session-picker-modal': () => closeSessionPicker(),
|
|
6449
|
+
'project-picker-modal': () => closeProjectPicker(),
|
|
6278
6450
|
'terminal-manager-modal': () => closeTerminalManager(),
|
|
6279
6451
|
'new-session-modal': () => closeNewSession(),
|
|
6280
6452
|
};
|
|
@@ -6395,6 +6567,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
6395
6567
|
toggleMessagePanel();
|
|
6396
6568
|
return;
|
|
6397
6569
|
}
|
|
6570
|
+
if (e.code === 'KeyP' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
6571
|
+
e.preventDefault();
|
|
6572
|
+
openProjectPicker();
|
|
6573
|
+
return;
|
|
6574
|
+
}
|
|
6398
6575
|
if (e.code === 'KeyM' && e.shiftKey) {
|
|
6399
6576
|
e.preventDefault();
|
|
6400
6577
|
const msgDetailModal = document.getElementById('msg-detail-modal');
|
|
@@ -6562,19 +6739,19 @@ document.addEventListener('keydown', (e) => {
|
|
|
6562
6739
|
}
|
|
6563
6740
|
if (e.key === '$' && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
6564
6741
|
e.preventDefault();
|
|
6565
|
-
|
|
6742
|
+
openCost(contextSid);
|
|
6566
6743
|
return;
|
|
6567
6744
|
}
|
|
6568
6745
|
if (matchKey(e, 'KeyM')) {
|
|
6569
6746
|
e.preventDefault();
|
|
6570
6747
|
const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
|
|
6571
|
-
|
|
6748
|
+
openMarketplace(mSession?.project);
|
|
6572
6749
|
return;
|
|
6573
6750
|
}
|
|
6574
6751
|
if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'm') {
|
|
6575
6752
|
e.preventDefault();
|
|
6576
6753
|
const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
|
|
6577
|
-
|
|
6754
|
+
openMemory(mSession?.project);
|
|
6578
6755
|
return;
|
|
6579
6756
|
}
|
|
6580
6757
|
if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
|
|
@@ -6608,11 +6785,17 @@ document.addEventListener('keydown', (e) => {
|
|
|
6608
6785
|
showToast('No session selected');
|
|
6609
6786
|
return;
|
|
6610
6787
|
}
|
|
6611
|
-
const
|
|
6612
|
-
const label = e.ctrlKey
|
|
6788
|
+
const peerName = e.ctrlKey ? sessions.find((s) => s.id === contextSid)?.peerName : null;
|
|
6789
|
+
const [text, label] = e.ctrlKey
|
|
6790
|
+
? [peerName && sendMessageAddress(peerName), 'session name']
|
|
6791
|
+
: [contextSid, 'session id'];
|
|
6792
|
+
if (!text) {
|
|
6793
|
+
showToast('No live session name: the session is not running');
|
|
6794
|
+
return;
|
|
6795
|
+
}
|
|
6613
6796
|
navigator.clipboard
|
|
6614
6797
|
.writeText(text)
|
|
6615
|
-
.then(() => showToast(`Copied ${label}: ${
|
|
6798
|
+
.then(() => showToast(`Copied ${label}: ${e.ctrlKey ? text : text.slice(0, 8)}`, 'success'))
|
|
6616
6799
|
.catch(() => showToast(`Failed to copy ${label}`));
|
|
6617
6800
|
return;
|
|
6618
6801
|
}
|
|
@@ -6939,6 +7122,7 @@ function handleSessionOpenEvent(data) {
|
|
|
6939
7122
|
stickySessionIds.add(id);
|
|
6940
7123
|
}
|
|
6941
7124
|
setSessionDismissed(id, false);
|
|
7125
|
+
cliOpenedId = id;
|
|
6942
7126
|
fetchTasks(id);
|
|
6943
7127
|
}
|
|
6944
7128
|
|
|
@@ -6963,7 +7147,7 @@ function handleDocumentLinkEvent(data) {
|
|
|
6963
7147
|
setSessionDocLink(sessionId, filePath, unlink);
|
|
6964
7148
|
const msg = `${unlink ? 'Unlinked' : 'Linked'} ${linkedDocLabel(filePath)}`;
|
|
6965
7149
|
// 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) });
|
|
7150
|
+
if (open && isOnScreen()) showToast(msg, 'info', { label: 'Open', onClick: () => hub.openExternal(filePath) });
|
|
6967
7151
|
else showToast(msg);
|
|
6968
7152
|
}
|
|
6969
7153
|
|
|
@@ -7090,7 +7274,7 @@ function linkedDocLabel(p) {
|
|
|
7090
7274
|
function openLinkedDoc(p, baseDir) {
|
|
7091
7275
|
const opener = linkedDocOpener(p);
|
|
7092
7276
|
if (opener === 'url') {
|
|
7093
|
-
openExternal(p);
|
|
7277
|
+
hub.openExternal(p);
|
|
7094
7278
|
return;
|
|
7095
7279
|
}
|
|
7096
7280
|
if (opener === 'preview') {
|
|
@@ -7118,11 +7302,11 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
7118
7302
|
const pathSpan = rel ? `<span class="linked-doc-path" title="${escapeHtml(p)}">${escapeHtml(rel)}</span>` : '';
|
|
7119
7303
|
const attr = escapeHtml(p);
|
|
7120
7304
|
const href = isUrl ? `href="${attr}" target="_blank" rel="noopener"` : 'href="#"';
|
|
7121
|
-
return `<li class="linked-doc-item${cls}">
|
|
7305
|
+
return `<li class="linked-doc-item${escapeHtml(cls)}">
|
|
7122
7306
|
<a ${href} class="linked-doc-link" data-path="${attr}" title="${escapeHtml(p + hint)}">${escapeHtml(name)}</a>${isUrl ? '<span class="linked-doc-external" aria-hidden="true">↗</span>' : ''}
|
|
7123
7307
|
${pathSpan}${tag ? `<span class="linked-doc-path">${tag}</span>` : ''}
|
|
7124
7308
|
<span class="row-actions linked-doc-actions">
|
|
7125
|
-
<button type="button" class="linked-doc-copy" data-path="${attr}" title="Copy ${noun}" aria-label="Copy ${noun} of ${escapeHtml(name)}">${ICON_COPY}</button>
|
|
7309
|
+
<button type="button" class="linked-doc-copy" data-path="${attr}" title="Copy ${escapeHtml(noun)}" aria-label="Copy ${escapeHtml(noun)} of ${escapeHtml(name)}">${ICON_COPY}</button>
|
|
7126
7310
|
<button type="button" class="linked-doc-remove" data-path="${attr}" title="Unlink" aria-label="Unlink ${escapeHtml(name)}">×</button>
|
|
7127
7311
|
</span>
|
|
7128
7312
|
</li>`;
|
|
@@ -7606,7 +7790,7 @@ function setupEventSource() {
|
|
|
7606
7790
|
if (data.type === 'dispatch-update') onDispatchUpdate();
|
|
7607
7791
|
|
|
7608
7792
|
if (data.type === 'agent-update') {
|
|
7609
|
-
pendingAgentSessionIds.add(data.sessionId);
|
|
7793
|
+
if (!data.unreadOnly) pendingAgentSessionIds.add(data.sessionId);
|
|
7610
7794
|
clearTimeout(agentRefreshTimer);
|
|
7611
7795
|
agentRefreshTimer = setTimeout(() => {
|
|
7612
7796
|
if (skipOffScreen()) return;
|
|
@@ -7658,6 +7842,7 @@ function setupEventSource() {
|
|
|
7658
7842
|
|
|
7659
7843
|
// When cck comes back on screen (tab visible or hub:active), catch up immediately
|
|
7660
7844
|
async function catchUp() {
|
|
7845
|
+
markOpenSessionRead();
|
|
7661
7846
|
if (!isOnScreen() || !missedWhileHidden) return;
|
|
7662
7847
|
missedWhileHidden = false;
|
|
7663
7848
|
// The full refresh below covers whatever the pending debounces would have fetched.
|
|
@@ -7678,12 +7863,11 @@ function setupEventSource() {
|
|
|
7678
7863
|
renderAgentFooter();
|
|
7679
7864
|
}
|
|
7680
7865
|
document.addEventListener('visibilitychange', catchUp);
|
|
7681
|
-
|
|
7682
|
-
|
|
7683
|
-
hubActive = !!e.data.active;
|
|
7866
|
+
onHubActive = (active) => {
|
|
7867
|
+
hubActive = active;
|
|
7684
7868
|
catchUp();
|
|
7685
7869
|
if (hubActive && wantsTerminal()) onTerminalShown();
|
|
7686
|
-
}
|
|
7870
|
+
};
|
|
7687
7871
|
|
|
7688
7872
|
// Fallback poll every 30s in case SSE silently drops; skip when not on screen
|
|
7689
7873
|
setInterval(() => {
|
|
@@ -8680,12 +8864,10 @@ function renderZenState() {
|
|
|
8680
8864
|
: 'Zen mode: current session only (Ctrl+Shift+Z)';
|
|
8681
8865
|
}
|
|
8682
8866
|
|
|
8683
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
8684
8867
|
function filterBySessions(value) {
|
|
8685
8868
|
if (value === sessionFilter) return;
|
|
8686
|
-
|
|
8869
|
+
setSessionFilter(value);
|
|
8687
8870
|
resetSessionPage();
|
|
8688
|
-
if (value !== 'active') activityFilter.clear();
|
|
8689
8871
|
renderFilterState();
|
|
8690
8872
|
updateUrl();
|
|
8691
8873
|
// Instant feedback from cached data, then refetch — the cached list was fetched
|
|
@@ -8696,6 +8878,11 @@ function filterBySessions(value) {
|
|
|
8696
8878
|
fetchSessions(false);
|
|
8697
8879
|
}
|
|
8698
8880
|
|
|
8881
|
+
function setSessionFilter(value) {
|
|
8882
|
+
sessionFilter = value;
|
|
8883
|
+
if (value !== 'active') activityFilter.clear();
|
|
8884
|
+
}
|
|
8885
|
+
|
|
8699
8886
|
function projectFilterText() {
|
|
8700
8887
|
return filterProject && filterProject !== '__recent__' ? filterProject : '';
|
|
8701
8888
|
}
|
|
@@ -8973,15 +9160,17 @@ const COLOR_THEMES = [
|
|
|
8973
9160
|
['synthwave', "Synthwave '84"],
|
|
8974
9161
|
];
|
|
8975
9162
|
|
|
8976
|
-
function buildThemeMenu() {
|
|
9163
|
+
function buildThemeMenu(themes = COLOR_THEMES) {
|
|
8977
9164
|
const menu = document.getElementById('themeMenu');
|
|
8978
|
-
menu.innerHTML =
|
|
8979
|
-
(
|
|
8980
|
-
|
|
9165
|
+
menu.innerHTML = themes
|
|
9166
|
+
.map(
|
|
9167
|
+
([id, label]) =>
|
|
9168
|
+
`<button type="button" class="theme-menu-item theme-swatch-${id}" data-theme-id="${id}"
|
|
8981
9169
|
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}
|
|
9170
|
+
<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
9171
|
</button>`,
|
|
8984
|
-
|
|
9172
|
+
)
|
|
9173
|
+
.join('');
|
|
8985
9174
|
}
|
|
8986
9175
|
|
|
8987
9176
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
@@ -9241,10 +9430,13 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
9241
9430
|
let html = '';
|
|
9242
9431
|
|
|
9243
9432
|
// 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.
|
|
9433
|
+
// Each row: [label, value, { openPath?, abbrev?, copy? }] — `value` is authoritative
|
|
9434
|
+
// (tooltip + copy); `abbrev` only replaces the rendered text, `copy` the copied text.
|
|
9246
9435
|
const infoRows = [];
|
|
9247
9436
|
infoRows.push(['Session', session.id, { openClaudeDir: true, openFile: session.jsonlPath }]);
|
|
9437
|
+
if (session.peerName) {
|
|
9438
|
+
infoRows.push(['Peer name', session.peerName, { copy: sendMessageAddress(session.peerName) }]);
|
|
9439
|
+
}
|
|
9248
9440
|
if (parentInfo?.parentSessionId) {
|
|
9249
9441
|
infoRows.push([
|
|
9250
9442
|
parentInfo.relation === 'compact' ? 'Continued from' : 'Forked from',
|
|
@@ -9298,7 +9490,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
9298
9490
|
} else {
|
|
9299
9491
|
html += `<span style="${plainStyle}" title="${escapeHtml(value)}">${escapeHtml(opts?.abbrev || value)}</span>`;
|
|
9300
9492
|
}
|
|
9301
|
-
const copyBtn = `<button onclick="copyWithFeedback('${escAttrJs(value)}', this)" title="Copy">${ICON_COPY}</button>`;
|
|
9493
|
+
const copyBtn = `<button onclick="copyWithFeedback('${escAttrJs(opts?.copy || value)}', this)" title="Copy">${ICON_COPY}</button>`;
|
|
9302
9494
|
let openBtn = '';
|
|
9303
9495
|
if (opts?.openClaudeDir || opts?.openPath) {
|
|
9304
9496
|
const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath);
|
|
@@ -9393,12 +9585,12 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
9393
9585
|
updateStickyBtnState();
|
|
9394
9586
|
updateDismissBtnState();
|
|
9395
9587
|
const costBtn = document.getElementById('session-info-cost-btn');
|
|
9396
|
-
if (costBtn) costBtn.style.display =
|
|
9588
|
+
if (costBtn) costBtn.style.display = hub.can('session.cost') ? '' : 'none';
|
|
9397
9589
|
const mkBtn = document.getElementById('session-info-marketplace-btn');
|
|
9398
9590
|
const memBtn = document.getElementById('session-info-memory-btn');
|
|
9399
9591
|
const proj = session.project;
|
|
9400
|
-
if (mkBtn) mkBtn.style.display = proj && (
|
|
9401
|
-
if (memBtn) memBtn.style.display = proj && (
|
|
9592
|
+
if (mkBtn) mkBtn.style.display = proj && hub.can('project.plugins') ? '' : 'none';
|
|
9593
|
+
if (memBtn) memBtn.style.display = proj && hub.can('project.memory') ? '' : 'none';
|
|
9402
9594
|
modal.classList.add('visible');
|
|
9403
9595
|
|
|
9404
9596
|
if (alreadyVisible) return; // re-render during deferred hydration — key handler already attached
|
|
@@ -9593,6 +9785,13 @@ function spDotHtml(session) {
|
|
|
9593
9785
|
return isActiveSession(session) ? SP_DOTS.active : SP_DOTS.idle;
|
|
9594
9786
|
}
|
|
9595
9787
|
|
|
9788
|
+
function spPinHtml(session) {
|
|
9789
|
+
const state = getSessionPinState(session.id);
|
|
9790
|
+
if (state === 'sticky') return `<span class="sp-pin sticky" title="Sticky">${SESSION_STAR_SVG}</span>`;
|
|
9791
|
+
if (state === 'pinned') return `<span class="sp-pin pinned" title="Pinned">${SESSION_PIN_SVG}</span>`;
|
|
9792
|
+
return '';
|
|
9793
|
+
}
|
|
9794
|
+
|
|
9596
9795
|
function renderSessionPicker() {
|
|
9597
9796
|
const list = document.getElementById('session-picker-list');
|
|
9598
9797
|
const query = document.getElementById('session-picker-input').value.trim();
|
|
@@ -9622,6 +9821,7 @@ function renderSessionPicker() {
|
|
|
9622
9821
|
return `<button class="sp-row${s.id === currentSessionId ? ' current' : ''}${outside ? ' outside' : ''}" data-idx="${i}" title="${escapeHtml(s.project ? `${s.id}\n${s.project}` : s.id)}">
|
|
9623
9822
|
${spDotHtml(s)}
|
|
9624
9823
|
<span class="sp-name">${escapeHtml(sessionDisplayName(s))}</span>
|
|
9824
|
+
${spPinHtml(s)}
|
|
9625
9825
|
<span class="sp-project">${escapeHtml(project)}</span>
|
|
9626
9826
|
${s.gitBranch ? `<span class="sp-branch">${escapeHtml(s.gitBranch)}</span>` : ''}
|
|
9627
9827
|
<span class="sp-count">${s.completed}/${s.taskCount}</span>
|
|
@@ -9685,6 +9885,122 @@ function initSessionPicker() {
|
|
|
9685
9885
|
}
|
|
9686
9886
|
//#endregion
|
|
9687
9887
|
|
|
9888
|
+
//#region PROJECT_PICKER
|
|
9889
|
+
// Sets only this board's project filter; a later hub project.changed replaces it.
|
|
9890
|
+
let ppRows = [];
|
|
9891
|
+
let ppIdx = 0;
|
|
9892
|
+
let ppSource = null;
|
|
9893
|
+
let ppRecentOnly = false;
|
|
9894
|
+
|
|
9895
|
+
async function openProjectPicker() {
|
|
9896
|
+
const input = document.getElementById('project-picker-input');
|
|
9897
|
+
input.value = '';
|
|
9898
|
+
renderProjectPicker();
|
|
9899
|
+
document.getElementById('project-picker-modal').classList.add('visible');
|
|
9900
|
+
input.focus();
|
|
9901
|
+
await refreshProjectList();
|
|
9902
|
+
ppSource = (projectsCache || [])
|
|
9903
|
+
.filter((p) => !p.temp)
|
|
9904
|
+
.map((p) => ({ ...p, norm: normalizeProjectPath(p.path) }))
|
|
9905
|
+
.sort((a, b) => (b.modifiedAt || '').localeCompare(a.modifiedAt || ''));
|
|
9906
|
+
renderProjectPicker();
|
|
9907
|
+
}
|
|
9908
|
+
|
|
9909
|
+
function ppToggleRecentOnly() {
|
|
9910
|
+
ppRecentOnly = !ppRecentOnly;
|
|
9911
|
+
document.getElementById('pp-recent-chip').setAttribute('aria-pressed', String(ppRecentOnly));
|
|
9912
|
+
renderProjectPicker();
|
|
9913
|
+
document.getElementById('project-picker-input').focus();
|
|
9914
|
+
}
|
|
9915
|
+
|
|
9916
|
+
// One fetch for both filters: two in a row could land out of order.
|
|
9917
|
+
function ppSetFilters(project, session) {
|
|
9918
|
+
closeProjectPicker();
|
|
9919
|
+
if (project === filterProject) return filterBySessions(session);
|
|
9920
|
+
setSessionFilter(session);
|
|
9921
|
+
filterByProject(project);
|
|
9922
|
+
}
|
|
9923
|
+
|
|
9924
|
+
function closeProjectPicker() {
|
|
9925
|
+
hideModalOverlay('project-picker-modal');
|
|
9926
|
+
}
|
|
9927
|
+
|
|
9928
|
+
function renderProjectPicker() {
|
|
9929
|
+
const list = document.getElementById('project-picker-list');
|
|
9930
|
+
const query = document.getElementById('project-picker-input').value.trim();
|
|
9931
|
+
const source = ppSource || [];
|
|
9932
|
+
const pool = ppRecentOnly ? source.filter((p) => recentProjects.has(p.path)) : source;
|
|
9933
|
+
ppRows = query ? pool.filter((p) => fuzzyMatch(p.path, query)) : pool;
|
|
9934
|
+
if (!ppRows.length) {
|
|
9935
|
+
ppIdx = -1;
|
|
9936
|
+
const empty = ppRecentOnly ? `No project active in the last ${RECENT_PROJECT_HOURS}h` : 'No project matches';
|
|
9937
|
+
list.innerHTML = `<div class="sp-empty">${ppSource ? empty : 'Loading projects…'}</div>`;
|
|
9938
|
+
return;
|
|
9939
|
+
}
|
|
9940
|
+
const current = filterProject && normalizeProjectPath(filterProject);
|
|
9941
|
+
list.innerHTML = ppRows
|
|
9942
|
+
.map((p, i) => {
|
|
9943
|
+
return `<button class="sp-row${p.norm === current ? ' current' : ''}${recentProjects.has(p.path) ? ' recent' : ''}" data-idx="${i}">
|
|
9944
|
+
<span class="sp-name">${escapeHtml(pathBasename(p.path))}</span>
|
|
9945
|
+
<span class="sp-project">${escapeHtml(p.path)}</span>
|
|
9946
|
+
${p.modifiedAt ? `<span class="sp-time">${formatDate(p.modifiedAt)}</span>` : ''}
|
|
9947
|
+
</button>`;
|
|
9948
|
+
})
|
|
9949
|
+
.join('');
|
|
9950
|
+
const currentIdx = ppRows.findIndex((p) => p.norm === current);
|
|
9951
|
+
ppSelect(query ? 0 : Math.max(0, currentIdx));
|
|
9952
|
+
}
|
|
9953
|
+
|
|
9954
|
+
function ppSelect(idx) {
|
|
9955
|
+
const list = document.getElementById('project-picker-list');
|
|
9956
|
+
list.children[ppIdx]?.classList.remove('selected');
|
|
9957
|
+
ppIdx = Math.min(Math.max(idx, 0), ppRows.length - 1);
|
|
9958
|
+
const row = list.children[ppIdx];
|
|
9959
|
+
row?.classList.add('selected');
|
|
9960
|
+
row?.scrollIntoView({ block: 'nearest' });
|
|
9961
|
+
}
|
|
9962
|
+
|
|
9963
|
+
function ppApply(idx) {
|
|
9964
|
+
const row = ppRows[idx];
|
|
9965
|
+
if (!row) return;
|
|
9966
|
+
ppSetFilters(row.path, ppRecentOnly ? 'active' : 'all');
|
|
9967
|
+
}
|
|
9968
|
+
|
|
9969
|
+
function initProjectPicker() {
|
|
9970
|
+
const input = document.getElementById('project-picker-input');
|
|
9971
|
+
input.addEventListener('input', renderProjectPicker);
|
|
9972
|
+
input.addEventListener('keydown', (e) => {
|
|
9973
|
+
if (e.key === 'Escape') closeProjectPicker();
|
|
9974
|
+
else if (matchKey(e, 'ArrowDown') || (e.ctrlKey && e.key === 'n')) ppSelect(ppIdx + 1);
|
|
9975
|
+
else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && !e.shiftKey && e.key === 'p')) ppSelect(ppIdx - 1);
|
|
9976
|
+
else if (e.key === 'Enter') ppApply(ppIdx);
|
|
9977
|
+
else if (e.altKey && !e.ctrlKey && !e.metaKey && e.code === 'KeyR') {
|
|
9978
|
+
if (!e.repeat) ppToggleRecentOnly();
|
|
9979
|
+
} else if (e.altKey && !e.ctrlKey && !e.metaKey && e.code === 'KeyC')
|
|
9980
|
+
ppSetFilters(FILTER_DEFAULTS.project, FILTER_DEFAULTS.session);
|
|
9981
|
+
else return;
|
|
9982
|
+
e.preventDefault();
|
|
9983
|
+
e.stopPropagation();
|
|
9984
|
+
});
|
|
9985
|
+
document.getElementById('project-picker-list').addEventListener('click', (e) => {
|
|
9986
|
+
const row = e.target.closest('.sp-row');
|
|
9987
|
+
if (row) ppApply(Number(row.dataset.idx));
|
|
9988
|
+
});
|
|
9989
|
+
// Vimium's Escape only blurs the input; see initSessionPicker.
|
|
9990
|
+
const modal = document.getElementById('project-picker-modal');
|
|
9991
|
+
let pointerDown = false;
|
|
9992
|
+
modal.addEventListener('mousedown', () => {
|
|
9993
|
+
pointerDown = true;
|
|
9994
|
+
});
|
|
9995
|
+
document.addEventListener('mouseup', () => {
|
|
9996
|
+
pointerDown = false;
|
|
9997
|
+
});
|
|
9998
|
+
input.addEventListener('blur', () => {
|
|
9999
|
+
if (!pointerDown && modal.classList.contains('visible') && document.hasFocus()) closeProjectPicker();
|
|
10000
|
+
});
|
|
10001
|
+
}
|
|
10002
|
+
//#endregion
|
|
10003
|
+
|
|
9688
10004
|
//#region PLAN
|
|
9689
10005
|
function refreshOpenPlan() {
|
|
9690
10006
|
if (!_planSessionId || !document.getElementById('plan-modal').classList.contains('visible')) return;
|
|
@@ -10062,35 +10378,16 @@ function openFolderInEditor(folder, file) {
|
|
|
10062
10378
|
postAndToast('/api/open-folder', body, 'folder');
|
|
10063
10379
|
}
|
|
10064
10380
|
|
|
10065
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
10066
10381
|
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
|
-
}
|
|
10382
|
+
hub.invoke('session.cost', sessionId ? { session: sessionId } : {});
|
|
10072
10383
|
}
|
|
10073
10384
|
|
|
10074
10385
|
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
|
-
}
|
|
10386
|
+
hub.invoke('project.plugins', projectPath ? { project: projectPath } : {});
|
|
10083
10387
|
}
|
|
10084
10388
|
|
|
10085
10389
|
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
|
-
}
|
|
10390
|
+
hub.invoke('project.memory', projectPath ? { project: projectPath } : {});
|
|
10094
10391
|
}
|
|
10095
10392
|
|
|
10096
10393
|
function openForInfoModalProject(open) {
|
|
@@ -10262,10 +10559,9 @@ function filterByOwner(value) {
|
|
|
10262
10559
|
// leaving the session view or switching sessions drops only the socket; coming back
|
|
10263
10560
|
// reattaches and the server replays the screen.
|
|
10264
10561
|
const TERMINAL_TOKEN_KEY = 'terminal-token';
|
|
10562
|
+
// Every config dir's board is served on the hub's one kanban origin, so sessionStorage is shared.
|
|
10563
|
+
const TERMINAL_FOCUS_KEY = nsKey('terminal-focus');
|
|
10265
10564
|
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
10565
|
const TERMINAL_MODES_KEY = 'terminal-sessions';
|
|
10270
10566
|
const TERMINAL_FONT_KEY = 'terminal-font-size';
|
|
10271
10567
|
const TERMINAL_FONT_MIN = 8;
|
|
@@ -10322,32 +10618,18 @@ let tokenRefresh = null;
|
|
|
10322
10618
|
// The hub mints a new token each run, and this page can outlive a hub restart. Resolves true only
|
|
10323
10619
|
// for a token that differs from the one that just failed, so a caller retries at most once.
|
|
10324
10620
|
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);
|
|
10621
|
+
tokenRefresh ??= hub
|
|
10622
|
+
.terminalToken()
|
|
10623
|
+
.then((token) => {
|
|
10624
|
+
if (!TERMINAL_TOKEN_RE.test(token) || token === terminalToken) return false;
|
|
10343
10625
|
terminalToken = token;
|
|
10344
10626
|
storeTerminalToken(token);
|
|
10345
|
-
|
|
10346
|
-
}
|
|
10347
|
-
|
|
10348
|
-
|
|
10349
|
-
|
|
10350
|
-
|
|
10627
|
+
return true;
|
|
10628
|
+
})
|
|
10629
|
+
.finally(() => {
|
|
10630
|
+
tokenRefresh = null;
|
|
10631
|
+
});
|
|
10632
|
+
return tokenRefresh;
|
|
10351
10633
|
}
|
|
10352
10634
|
|
|
10353
10635
|
function terminalAvailable() {
|
|
@@ -10384,6 +10666,11 @@ function terminalPaneFocused() {
|
|
|
10384
10666
|
|
|
10385
10667
|
const TEXT_FIELD_SELECTOR = 'input, textarea, select, [contenteditable]';
|
|
10386
10668
|
|
|
10669
|
+
function inPageField(e) {
|
|
10670
|
+
const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
|
|
10671
|
+
return !!field && !document.getElementById('terminal-pane').contains(field);
|
|
10672
|
+
}
|
|
10673
|
+
|
|
10387
10674
|
function terminalShortcut(e) {
|
|
10388
10675
|
const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
|
|
10389
10676
|
const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
|
|
@@ -10393,13 +10680,11 @@ function terminalShortcut(e) {
|
|
|
10393
10680
|
return () => adjustTerminalFontSize(Math.sign(zoom));
|
|
10394
10681
|
}
|
|
10395
10682
|
if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
|
|
10683
|
+
if (matchKey(e, 'KeyR') && terminalConnectSlow() && wantsTerminal() && !inPageField(e)) return reconnectTerminal;
|
|
10396
10684
|
// Cancelling the default also stops Chrome's system print dialog, which this page has no use for.
|
|
10397
10685
|
if (ctrlShift && e.code === 'KeyP') return toggleSessionPicker;
|
|
10398
10686
|
// 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
|
-
}
|
|
10687
|
+
if (ctrlShift && e.code === 'KeyZ') return inPageField(e) ? null : toggleZenMode;
|
|
10403
10688
|
if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
|
|
10404
10689
|
return () => openNewSession(null, e.code === 'KeyR');
|
|
10405
10690
|
}
|
|
@@ -10493,6 +10778,7 @@ function openSession(sessionId) {
|
|
|
10493
10778
|
const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
|
|
10494
10779
|
if (!alreadyOpen) termState.openedByUser = sessionId;
|
|
10495
10780
|
else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
|
|
10781
|
+
markSessionRead(sessionId);
|
|
10496
10782
|
return fetchTasks(sessionId);
|
|
10497
10783
|
}
|
|
10498
10784
|
|
|
@@ -10595,7 +10881,7 @@ function terminalKeyFilter(e) {
|
|
|
10595
10881
|
return false;
|
|
10596
10882
|
}
|
|
10597
10883
|
if (ctrlOnly && e.code === 'KeyV') return false;
|
|
10598
|
-
return !
|
|
10884
|
+
return !hub.forwards(e);
|
|
10599
10885
|
}
|
|
10600
10886
|
|
|
10601
10887
|
function oscColor(hex) {
|
|
@@ -10765,22 +11051,44 @@ function hideTerminalPrompt() {
|
|
|
10765
11051
|
|
|
10766
11052
|
const TERMINAL_BOOT_VERBS = ['Waking Claude…', 'Clawding…', 'Warming up the terminal…', 'Summoning the session…'];
|
|
10767
11053
|
const TERMINAL_BOOT_MAX_MS = 15000;
|
|
11054
|
+
// The server answers `ready` as soon as the socket attaches, even before Claude draws, so a wait
|
|
11055
|
+
// this long is the connection: under the hub a loopback connect can hang until its timeout.
|
|
11056
|
+
const TERMINAL_SLOW_CONNECT_MS = 1500;
|
|
10768
11057
|
let terminalBootTimer = null;
|
|
11058
|
+
let terminalSlowTimer = null;
|
|
10769
11059
|
|
|
10770
11060
|
// A new PTY shows only a blinking cursor until Claude draws its first frame.
|
|
10771
11061
|
function showTerminalBoot() {
|
|
10772
11062
|
document.getElementById('terminal-boot-verb').textContent = pickRandom(TERMINAL_BOOT_VERBS);
|
|
10773
11063
|
document.getElementById('terminal-boot').classList.add('visible');
|
|
10774
11064
|
clearTimeout(terminalBootTimer);
|
|
10775
|
-
terminalBootTimer = setTimeout(
|
|
11065
|
+
terminalBootTimer = setTimeout(() => {
|
|
11066
|
+
if (!terminalConnectSlow()) hideTerminalBoot();
|
|
11067
|
+
}, TERMINAL_BOOT_MAX_MS);
|
|
10776
11068
|
}
|
|
10777
11069
|
|
|
10778
11070
|
function hideTerminalBoot() {
|
|
10779
11071
|
clearTimeout(terminalBootTimer);
|
|
10780
11072
|
terminalBootTimer = null;
|
|
11073
|
+
clearTerminalSlow();
|
|
10781
11074
|
document.getElementById('terminal-boot').classList.remove('visible');
|
|
10782
11075
|
}
|
|
10783
11076
|
|
|
11077
|
+
function clearTerminalSlow() {
|
|
11078
|
+
clearTimeout(terminalSlowTimer);
|
|
11079
|
+
terminalSlowTimer = null;
|
|
11080
|
+
document.getElementById('terminal-boot').classList.remove('slow');
|
|
11081
|
+
}
|
|
11082
|
+
|
|
11083
|
+
function terminalConnectSlow() {
|
|
11084
|
+
return document.getElementById('terminal-boot').classList.contains('slow');
|
|
11085
|
+
}
|
|
11086
|
+
|
|
11087
|
+
function reconnectTerminal() {
|
|
11088
|
+
const id = termState.sessionId;
|
|
11089
|
+
if (id) openTerminal(id, terminalOpenMode(id));
|
|
11090
|
+
}
|
|
11091
|
+
|
|
10784
11092
|
function showTerminalPrompt(sessionId, title, detail, choices, output = '') {
|
|
10785
11093
|
const el = document.getElementById('terminal-prompt');
|
|
10786
11094
|
const tail = output ? `<pre class="${TINTED_PRE_CLASS} terminal-prompt-output">${escapeHtml(output)}</pre>` : '';
|
|
@@ -10821,13 +11129,41 @@ function syncCloseGuard() {
|
|
|
10821
11129
|
const on = termState.attached && termState.shown;
|
|
10822
11130
|
if (termState.closeGuard === on) return;
|
|
10823
11131
|
termState.closeGuard = on;
|
|
10824
|
-
|
|
11132
|
+
hub.closeGuard(on);
|
|
10825
11133
|
}
|
|
10826
11134
|
|
|
10827
11135
|
window.addEventListener('beforeunload', (e) => {
|
|
10828
|
-
if (termState.closeGuard && !
|
|
11136
|
+
if (termState.closeGuard && !hub.inHub) e.preventDefault();
|
|
11137
|
+
});
|
|
11138
|
+
|
|
11139
|
+
// A config-dir switch in the hub reloads this page, and coming back should put the cursor where it was.
|
|
11140
|
+
// The hub's palette takes focus first and clears activeElement here, so the last focus inside this
|
|
11141
|
+
// page is tracked instead; focus leaving the frame keeps it.
|
|
11142
|
+
let terminalHadFocus = false;
|
|
11143
|
+
document.addEventListener('focusin', () => {
|
|
11144
|
+
terminalHadFocus = terminalPaneFocused();
|
|
11145
|
+
});
|
|
11146
|
+
document.addEventListener('focusout', (e) => {
|
|
11147
|
+
if (e.relatedTarget) return;
|
|
11148
|
+
setTimeout(() => {
|
|
11149
|
+
if (document.hasFocus()) terminalHadFocus = terminalPaneFocused();
|
|
11150
|
+
});
|
|
11151
|
+
});
|
|
11152
|
+
window.addEventListener('pagehide', () => {
|
|
11153
|
+
try {
|
|
11154
|
+
if (termState.attached && termState.shown && terminalHadFocus) {
|
|
11155
|
+
sessionStorage.setItem(TERMINAL_FOCUS_KEY, currentSessionId);
|
|
11156
|
+
} else sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
|
|
11157
|
+
} catch (_) {}
|
|
10829
11158
|
});
|
|
10830
11159
|
|
|
11160
|
+
function restoreTerminalFocus(sessionId) {
|
|
11161
|
+
try {
|
|
11162
|
+
if (sessionStorage.getItem(TERMINAL_FOCUS_KEY) === sessionId) termState.focusNext = true;
|
|
11163
|
+
sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
|
|
11164
|
+
} catch (_) {}
|
|
11165
|
+
}
|
|
11166
|
+
|
|
10831
11167
|
// The terminal stands in for the board, so it takes the board zone however focus arrives
|
|
10832
11168
|
// (Tab, Alt+`, a click); leaveTerminalPane hands it back to the sidebar.
|
|
10833
11169
|
document.getElementById('terminal-pane').addEventListener('focusin', () => {
|
|
@@ -10876,6 +11212,11 @@ async function openTerminal(sessionId, mode, attempt = 0) {
|
|
|
10876
11212
|
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/terminal/ws`);
|
|
10877
11213
|
ws.binaryType = 'arraybuffer';
|
|
10878
11214
|
termState.ws = ws;
|
|
11215
|
+
if (!attempt) {
|
|
11216
|
+
terminalSlowTimer = setTimeout(() => {
|
|
11217
|
+
if (termState.ws === ws) document.getElementById('terminal-boot').classList.add('slow');
|
|
11218
|
+
}, TERMINAL_SLOW_CONNECT_MS);
|
|
11219
|
+
}
|
|
10879
11220
|
const spec = newSpecs.get(sessionId);
|
|
10880
11221
|
let readyAt = 0;
|
|
10881
11222
|
let refused = false;
|
|
@@ -10978,6 +11319,7 @@ function onTerminalMessage(sessionId, msg) {
|
|
|
10978
11319
|
// The server has the prompt now; a later start of the same placeholder must not send it again.
|
|
10979
11320
|
const spec = newSpecs.get(sessionId);
|
|
10980
11321
|
if (spec) spec.prompt = null;
|
|
11322
|
+
clearTerminalSlow();
|
|
10981
11323
|
hideTerminalPrompt();
|
|
10982
11324
|
setTerminalStatus('');
|
|
10983
11325
|
setTerminalAttached(true);
|
|
@@ -11270,6 +11612,7 @@ async function loadTerminals() {
|
|
|
11270
11612
|
const res = await fetch('/api/terminals', { cache: 'no-store' });
|
|
11271
11613
|
const list = (await res.json()).sessions || [];
|
|
11272
11614
|
runningTerminals = new Set(list.map((t) => t.id));
|
|
11615
|
+
renderActivityChip();
|
|
11273
11616
|
return list;
|
|
11274
11617
|
}
|
|
11275
11618
|
|
|
@@ -11277,6 +11620,7 @@ function setRunningTerminals(ids) {
|
|
|
11277
11620
|
runningTerminals = new Set(ids);
|
|
11278
11621
|
if (ids.some((id) => !sessions.some((s) => s.id === id))) fetchSessions(false).catch(() => {});
|
|
11279
11622
|
else renderSessions();
|
|
11623
|
+
renderActivityChip();
|
|
11280
11624
|
}
|
|
11281
11625
|
|
|
11282
11626
|
function showSessionTerminal(sessionId) {
|
|
@@ -11637,6 +11981,7 @@ try {
|
|
|
11637
11981
|
loadSessionGroups();
|
|
11638
11982
|
initSessionGroupsDnd();
|
|
11639
11983
|
initSessionPicker();
|
|
11984
|
+
initProjectPicker();
|
|
11640
11985
|
try {
|
|
11641
11986
|
const af = JSON.parse(store.getItem('activityFilter') || '[]');
|
|
11642
11987
|
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
@@ -11828,21 +12173,12 @@ if (urlState.search) {
|
|
|
11828
12173
|
document.getElementById('boot-verb').textContent = pickRandom(verbs);
|
|
11829
12174
|
}
|
|
11830
12175
|
|
|
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
|
-
])
|
|
12176
|
+
fetch('/api/config')
|
|
12177
|
+
.then((r) => r.json())
|
|
12178
|
+
.then((c) => {
|
|
12179
|
+
appConfig = c;
|
|
12180
|
+
})
|
|
12181
|
+
.catch(() => {})
|
|
11846
12182
|
.then(restorePendingSessions)
|
|
11847
12183
|
.then(() => fetchSessions())
|
|
11848
12184
|
.then(async () => {
|
|
@@ -11853,6 +12189,7 @@ Promise.all([
|
|
|
11853
12189
|
showNoSession();
|
|
11854
12190
|
}
|
|
11855
12191
|
} else if (urlState.session) {
|
|
12192
|
+
restoreTerminalFocus(urlState.session);
|
|
11856
12193
|
await fetchTasks(urlState.session);
|
|
11857
12194
|
} else {
|
|
11858
12195
|
const last = loadLastView();
|
|
@@ -11863,6 +12200,7 @@ Promise.all([
|
|
|
11863
12200
|
showNoSession();
|
|
11864
12201
|
}
|
|
11865
12202
|
} else if (last?.view === 'session' && last.session && sessions.some((s) => s.id === last.session)) {
|
|
12203
|
+
restoreTerminalFocus(last.session);
|
|
11866
12204
|
await fetchTasks(last.session);
|
|
11867
12205
|
} else {
|
|
11868
12206
|
showNoSession();
|
|
@@ -11906,49 +12244,65 @@ window.addEventListener('popstate', () => {
|
|
|
11906
12244
|
//#endregion
|
|
11907
12245
|
|
|
11908
12246
|
// #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;
|
|
12247
|
+
const costDetail = (session) => `?view=detail&session=${encodeURIComponent(session)}`;
|
|
12248
|
+
const projectQuery = (project) => `?${new URLSearchParams({ project })}`;
|
|
12249
|
+
const hub = ClaudeHub.connect({
|
|
12250
|
+
// A function, because the --*-url flags arrive with /api/config after connect.
|
|
12251
|
+
standalone: () => ({
|
|
12252
|
+
...(appConfig.costUrl && {
|
|
12253
|
+
'session.cost': (p) => p.session && new URL(costDetail(p.session), appConfig.costUrl).href,
|
|
12254
|
+
}),
|
|
12255
|
+
...(appConfig.marketplaceUrl && {
|
|
12256
|
+
'project.plugins': (p) => p.project && new URL(projectQuery(p.project), appConfig.marketplaceUrl).href,
|
|
12257
|
+
}),
|
|
12258
|
+
...(appConfig.memoryUrl && {
|
|
12259
|
+
'project.memory': (p) => p.project && new URL(projectQuery(p.project), appConfig.memoryUrl).href,
|
|
12260
|
+
}),
|
|
12261
|
+
}),
|
|
12262
|
+
});
|
|
11934
12263
|
|
|
11935
|
-
|
|
11936
|
-
|
|
11937
|
-
|
|
11938
|
-
|
|
11939
|
-
|
|
11940
|
-
|
|
11941
|
-
|
|
12264
|
+
hub.onActive((active) => onHubActive(active));
|
|
12265
|
+
|
|
12266
|
+
// session.changed goes out once the switch settles, so arrowing through the list sends one event.
|
|
12267
|
+
let publishTimer = 0;
|
|
12268
|
+
let publishedSessionId;
|
|
12269
|
+
let cliOpenedId = null;
|
|
12270
|
+
|
|
12271
|
+
function schedulePublishSession() {
|
|
12272
|
+
clearTimeout(publishTimer);
|
|
12273
|
+
publishTimer = setTimeout(publishSession, 150);
|
|
12274
|
+
}
|
|
12275
|
+
|
|
12276
|
+
function publishSession() {
|
|
12277
|
+
if (currentSessionId === publishedSessionId) return;
|
|
12278
|
+
const s = currentSessionId ? sessions.find((x) => x.id === currentSessionId) : null;
|
|
12279
|
+
// A placeholder publishes once its real row replaces it.
|
|
12280
|
+
if (currentSessionId && (!s || s.placeholder)) return;
|
|
12281
|
+
publishedSessionId = currentSessionId;
|
|
12282
|
+
const source = currentSessionId === cliOpenedId ? 'cli' : 'user';
|
|
12283
|
+
cliOpenedId = null;
|
|
12284
|
+
hub.publish(
|
|
12285
|
+
'session.changed',
|
|
12286
|
+
s && {
|
|
12287
|
+
sessionId: s.id,
|
|
12288
|
+
project: s.project,
|
|
12289
|
+
name: s.name || null,
|
|
12290
|
+
gitBranch: s.gitBranch || null,
|
|
12291
|
+
live: isSessionActive(s),
|
|
12292
|
+
source,
|
|
12293
|
+
},
|
|
12294
|
+
);
|
|
11942
12295
|
}
|
|
11943
12296
|
|
|
11944
|
-
|
|
11945
|
-
|
|
12297
|
+
// The help modal stops click propagation, so the document handler below never sees this link.
|
|
12298
|
+
// The service worker can pair a cached page that lacks the link with this script.
|
|
12299
|
+
document.getElementById('help-docs')?.addEventListener('click', (e) => {
|
|
11946
12300
|
e.preventDefault();
|
|
11947
|
-
|
|
12301
|
+
hub.openExternal(e.currentTarget.href);
|
|
11948
12302
|
});
|
|
11949
12303
|
|
|
11950
12304
|
document.addEventListener('click', (e) => {
|
|
11951
|
-
if (!
|
|
12305
|
+
if (!hub.inHub) return;
|
|
11952
12306
|
const a = e.target.closest?.('a[href]');
|
|
11953
12307
|
if (!a) return;
|
|
11954
12308
|
const href = a.getAttribute('href');
|
|
@@ -11963,78 +12317,34 @@ document.addEventListener('click', (e) => {
|
|
|
11963
12317
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
|
|
11964
12318
|
e.preventDefault();
|
|
11965
12319
|
e.stopPropagation();
|
|
11966
|
-
|
|
12320
|
+
hub.openExternal(url.href);
|
|
11967
12321
|
});
|
|
11968
12322
|
|
|
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
12323
|
(function initHubTheme() {
|
|
11992
12324
|
const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
|
|
11993
12325
|
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
|
-
}
|
|
12326
|
+
const report = hub.bindTheme({
|
|
12327
|
+
get: () => ({ theme: getTheme(), colorTheme: getColorTheme() }),
|
|
12328
|
+
set: ({ theme, colorTheme }) => {
|
|
12329
|
+
if (colorTheme !== getColorTheme()) setColorTheme(colorTheme);
|
|
12330
|
+
if (theme !== getTheme()) window.toggleTheme();
|
|
12331
|
+
},
|
|
12009
12332
|
});
|
|
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, {
|
|
12333
|
+
new MutationObserver(report).observe(document.body, {
|
|
12018
12334
|
attributes: true,
|
|
12019
12335
|
attributeFilter: ['class', 'data-color-theme'],
|
|
12020
12336
|
});
|
|
12021
12337
|
})();
|
|
12022
12338
|
|
|
12023
|
-
(
|
|
12024
|
-
|
|
12025
|
-
|
|
12026
|
-
|
|
12027
|
-
|
|
12028
|
-
});
|
|
12029
|
-
})();
|
|
12339
|
+
// Under the hub the picker lists the hub's themes, which include the user's own (protocol section 6, rule 5).
|
|
12340
|
+
hub.onThemes((themes) => {
|
|
12341
|
+
buildThemeMenu(themes.map((t) => [t.id, t.label]));
|
|
12342
|
+
syncColorThemeSelect(document.body.dataset.colorTheme);
|
|
12343
|
+
});
|
|
12030
12344
|
|
|
12031
|
-
(
|
|
12032
|
-
|
|
12033
|
-
|
|
12034
|
-
|
|
12035
|
-
|
|
12036
|
-
if (typeof dirPath !== 'string' || !dirPath || dirPath === filterProject) return;
|
|
12037
|
-
filterByProject(dirPath);
|
|
12038
|
-
});
|
|
12039
|
-
})();
|
|
12345
|
+
hub.subscribe('project.changed', (p) => {
|
|
12346
|
+
const dirPath = p?.project;
|
|
12347
|
+
if (typeof dirPath !== 'string' || !dirPath || dirPath === filterProject) return;
|
|
12348
|
+
filterByProject(dirPath);
|
|
12349
|
+
});
|
|
12040
12350
|
// #endregion HUB_INTEGRATION
|