claude-code-kanban 7.2.0 → 7.4.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/hub-app.json +1 -0
- package/lib/terminal.js +72 -33
- package/package.json +1 -1
- package/public/app.js +321 -126
- package/public/index.html +11 -7
- package/public/style.css +70 -13
- package/public/terminal-frame.js +6 -4
- package/public/vendor/claude-hub-sdk.js +40 -6
package/public/app.js
CHANGED
|
@@ -53,6 +53,7 @@ const collapsedProjectGroups = new Set();
|
|
|
53
53
|
const SECTION_GROUPS = '__section_groups__';
|
|
54
54
|
const SECTION_PROJECTS = '__section_projects__';
|
|
55
55
|
const SECTION_SESSIONS = '__section_sessions__';
|
|
56
|
+
const SECTION_OUTSIDE = '__section_outside__';
|
|
56
57
|
// Project paths and session ids in Active-view sidebar order, kept across reloads so live
|
|
57
58
|
// activity does not reshuffle the list. Each holds only what the loaded list still has.
|
|
58
59
|
const PROJECT_ORDER_KEY = 'project-order';
|
|
@@ -373,6 +374,65 @@ async function fetchSessionById(id) {
|
|
|
373
374
|
return (await fetchSessionsByIds([id]))[0] || null;
|
|
374
375
|
}
|
|
375
376
|
|
|
377
|
+
// The id may be outside the list the filters asked for. renderSession bails on a session
|
|
378
|
+
// missing from `sessions`, so pull that one row in before opening it; later polls
|
|
379
|
+
// keep it, because fetchSessions pins currentSessionId.
|
|
380
|
+
async function ensureSessionLoaded(id) {
|
|
381
|
+
const loaded = sessions.find((s) => s.id === id);
|
|
382
|
+
if (loaded) return loaded;
|
|
383
|
+
const session = await fetchSessionById(id);
|
|
384
|
+
if (!session) return null;
|
|
385
|
+
const raced = sessions.find((s) => s.id === id);
|
|
386
|
+
if (raced) return raced;
|
|
387
|
+
sessions.push(session);
|
|
388
|
+
return session;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Matches SESSION_SEARCH_MIN in server.js, which answers [] below it.
|
|
392
|
+
const SESSION_SEARCH_MIN = 3;
|
|
393
|
+
const SESSION_SEARCH_DEBOUNCE_MS = 100;
|
|
394
|
+
|
|
395
|
+
// Searches session ids and names in every transcript on the server, so a session outside the
|
|
396
|
+
// sidebar filter is found too. `rows` answers the server rows not in `localRows`, and whether
|
|
397
|
+
// the search for that query is still out; `onFound()` runs when the current query's rows arrive.
|
|
398
|
+
// Only the current query is kept, and a short query drops it, so a later identical query fetches again.
|
|
399
|
+
function sessionSearch(onFound) {
|
|
400
|
+
let current = null;
|
|
401
|
+
let found = null;
|
|
402
|
+
let timer = null;
|
|
403
|
+
const run = async (key) => {
|
|
404
|
+
let ids = [];
|
|
405
|
+
try {
|
|
406
|
+
const r = await fetch(`/api/sessions/search?q=${encodeURIComponent(key)}`);
|
|
407
|
+
if (r.ok) ids = await r.json();
|
|
408
|
+
} catch (_) {}
|
|
409
|
+
if (key !== current) return;
|
|
410
|
+
const rows = ids.length ? await fetchSessionsByIds(ids) : [];
|
|
411
|
+
if (key !== current) return;
|
|
412
|
+
found = rows;
|
|
413
|
+
onFound();
|
|
414
|
+
};
|
|
415
|
+
return {
|
|
416
|
+
rows(query, localRows) {
|
|
417
|
+
const key = query.length < SESSION_SEARCH_MIN ? null : query.toLowerCase();
|
|
418
|
+
// Set once per query: the sidebar re-renders on every live update, and resetting the
|
|
419
|
+
// timer each time would starve the search.
|
|
420
|
+
if (key !== current) {
|
|
421
|
+
clearTimeout(timer);
|
|
422
|
+
current = key;
|
|
423
|
+
found = null;
|
|
424
|
+
if (key) timer = setTimeout(() => run(key), SESSION_SEARCH_DEBOUNCE_MS);
|
|
425
|
+
}
|
|
426
|
+
if (!key) return { rows: [], pending: false };
|
|
427
|
+
if (!found) return { rows: [], pending: true };
|
|
428
|
+
const seen = new Set(localRows.map((s) => s.id));
|
|
429
|
+
return { rows: found.filter((s) => !seen.has(s.id)), pending: false };
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const sidebarSearch = sessionSearch(renderSessions);
|
|
435
|
+
|
|
376
436
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
377
437
|
function handleSearch(query) {
|
|
378
438
|
const wasSearching = !!searchQuery;
|
|
@@ -2494,8 +2554,13 @@ function clampModalZoom(v) {
|
|
|
2494
2554
|
return Math.min(MODAL_ZOOM_MAX, Math.max(MODAL_ZOOM_MIN, v));
|
|
2495
2555
|
}
|
|
2496
2556
|
|
|
2557
|
+
function modalZoomLabel() {
|
|
2558
|
+
return `${Math.round(modalZoom * 100)}%`;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2497
2561
|
function applyModalZoom() {
|
|
2498
2562
|
document.documentElement.style.setProperty('--modal-zoom', String(modalZoom));
|
|
2563
|
+
for (const el of document.querySelectorAll('.pane-zoom-val')) el.textContent = modalZoomLabel();
|
|
2499
2564
|
}
|
|
2500
2565
|
|
|
2501
2566
|
// delta 0 resets to 100%
|
|
@@ -2505,7 +2570,7 @@ function adjustModalZoom(delta) {
|
|
|
2505
2570
|
modalZoom = next;
|
|
2506
2571
|
store.setItem(MODAL_ZOOM_KEY, String(modalZoom));
|
|
2507
2572
|
applyModalZoom();
|
|
2508
|
-
showToast(`Text ${
|
|
2573
|
+
showToast(`Text ${modalZoomLabel()}`);
|
|
2509
2574
|
}
|
|
2510
2575
|
|
|
2511
2576
|
// The markup decides what scales: `.modal-zoomable` marks a modal's reading
|
|
@@ -3401,15 +3466,8 @@ async function revealSession(id) {
|
|
|
3401
3466
|
let session = sessions.find((s) => s.id === id);
|
|
3402
3467
|
if (!session) {
|
|
3403
3468
|
lastSessionsHash = '';
|
|
3404
|
-
await fetchSessions();
|
|
3405
|
-
session =
|
|
3406
|
-
}
|
|
3407
|
-
if (!session) {
|
|
3408
|
-
// The id is outside the list the filters asked for. renderSession bails on a session
|
|
3409
|
-
// missing from `sessions`, so pull that one row in before opening it; later polls
|
|
3410
|
-
// keep it, because fetchSessions pins currentSessionId.
|
|
3411
|
-
session = await fetchSessionById(id);
|
|
3412
|
-
if (session) sessions.push(session);
|
|
3469
|
+
await fetchSessions(true, id);
|
|
3470
|
+
session = await ensureSessionLoaded(id);
|
|
3413
3471
|
}
|
|
3414
3472
|
const uncollapsed = session ? uncollapseFor(session) : false;
|
|
3415
3473
|
if (uncollapsed) persistCollapsedGroups();
|
|
@@ -3598,36 +3656,8 @@ function renderSessions() {
|
|
|
3598
3656
|
const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
3599
3657
|
const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : applyStableSessionOrder(getFilteredSessions());
|
|
3600
3658
|
|
|
3601
|
-
|
|
3602
|
-
let emptyMsg = 'No sessions found';
|
|
3603
|
-
let emptyHint = 'Tasks appear when you use Claude Code';
|
|
3604
|
-
|
|
3605
|
-
if (zenMode) {
|
|
3606
|
-
emptyMsg = 'Zen mode: no session open';
|
|
3607
|
-
emptyHint = 'Press Ctrl+Shift+Z to leave zen mode, then open a session';
|
|
3608
|
-
} else if (searchQuery) {
|
|
3609
|
-
emptyMsg = `No results for "${searchQuery}"`;
|
|
3610
|
-
emptyHint = 'Try a different search term or clear the search';
|
|
3611
|
-
} else if (filterProject && sessionFilter === 'active') {
|
|
3612
|
-
emptyMsg = 'No active sessions for this project filter';
|
|
3613
|
-
emptyHint = 'Try "All Sessions" or clear the project filter';
|
|
3614
|
-
} else if (filterProject) {
|
|
3615
|
-
emptyMsg = 'No sessions for this project filter';
|
|
3616
|
-
emptyHint = 'Clear the project filter to see all';
|
|
3617
|
-
} else if (sessionFilter === 'active') {
|
|
3618
|
-
emptyMsg = 'No active sessions';
|
|
3619
|
-
emptyHint = 'Select "All Sessions" to see all';
|
|
3620
|
-
}
|
|
3621
|
-
sessionsList.innerHTML = `
|
|
3622
|
-
<div style="padding: 24px 12px; text-align: center; color: var(--text-muted); font-size: 12px;">
|
|
3623
|
-
<p>${emptyMsg}</p>
|
|
3624
|
-
<p style="margin-top: 8px; font-size: 11px;">${emptyHint}</p>
|
|
3625
|
-
</div>
|
|
3626
|
-
`;
|
|
3627
|
-
return;
|
|
3628
|
-
}
|
|
3659
|
+
const outside = sidebarSearch.rows(zenMode ? '' : searchQuery, filteredSessions);
|
|
3629
3660
|
|
|
3630
|
-
// Helper to render a single session card
|
|
3631
3661
|
const renderSessionCard = (session) => {
|
|
3632
3662
|
if (session.placeholder) return renderPlaceholderCard(session);
|
|
3633
3663
|
const total = session.taskCount;
|
|
@@ -3698,6 +3728,63 @@ function renderSessions() {
|
|
|
3698
3728
|
`;
|
|
3699
3729
|
};
|
|
3700
3730
|
|
|
3731
|
+
// `ungroup` marks the label as the drop target for pulling an item out of a named group.
|
|
3732
|
+
const sectionHtml = (key, text, ungroup, body, empty = false) => {
|
|
3733
|
+
const collapsed = collapsedProjectGroups.has(key);
|
|
3734
|
+
const escPath = escapeHtml(key);
|
|
3735
|
+
const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
|
|
3736
|
+
const emptyCls = empty ? ' sg-section-empty' : '';
|
|
3737
|
+
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>
|
|
3738
|
+
<div class="sg-section-body${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}">${body}</div>`;
|
|
3739
|
+
};
|
|
3740
|
+
|
|
3741
|
+
const countHtml = (arr) => {
|
|
3742
|
+
const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
3743
|
+
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>`;
|
|
3744
|
+
};
|
|
3745
|
+
|
|
3746
|
+
const outsideHtml = outside.rows.length
|
|
3747
|
+
? sectionHtml(
|
|
3748
|
+
SECTION_OUTSIDE,
|
|
3749
|
+
`Outside current filters ${countHtml(outside.rows)}`,
|
|
3750
|
+
false,
|
|
3751
|
+
collapsedProjectGroups.has(SECTION_OUTSIDE) ? '' : outside.rows.map(renderSessionCard).join(''),
|
|
3752
|
+
)
|
|
3753
|
+
: '';
|
|
3754
|
+
|
|
3755
|
+
if (filteredSessions.length === 0) {
|
|
3756
|
+
let emptyMsg = 'No sessions found';
|
|
3757
|
+
let emptyHint = 'Tasks appear when you use Claude Code';
|
|
3758
|
+
|
|
3759
|
+
if (zenMode) {
|
|
3760
|
+
emptyMsg = 'Zen mode: no session open';
|
|
3761
|
+
emptyHint = 'Press Ctrl+Shift+Z to leave zen mode, then open a session';
|
|
3762
|
+
} else if (searchQuery) {
|
|
3763
|
+
emptyMsg = `No results for "${escapeHtml(searchQuery)}"`;
|
|
3764
|
+
emptyHint = outside.rows.length
|
|
3765
|
+
? 'Matches outside the current filters are below'
|
|
3766
|
+
: outside.pending
|
|
3767
|
+
? 'Searching all sessions…'
|
|
3768
|
+
: 'Try a different search term or clear the search';
|
|
3769
|
+
} else if (filterProject && sessionFilter === 'active') {
|
|
3770
|
+
emptyMsg = 'No active sessions for this project filter';
|
|
3771
|
+
emptyHint = 'Try "All Sessions" or clear the project filter';
|
|
3772
|
+
} else if (filterProject) {
|
|
3773
|
+
emptyMsg = 'No sessions for this project filter';
|
|
3774
|
+
emptyHint = 'Clear the project filter to see all';
|
|
3775
|
+
} else if (sessionFilter === 'active') {
|
|
3776
|
+
emptyMsg = 'No active sessions';
|
|
3777
|
+
emptyHint = 'Select "All Sessions" to see all';
|
|
3778
|
+
}
|
|
3779
|
+
sessionsList.innerHTML = `
|
|
3780
|
+
<div style="padding: 24px 12px; text-align: center; color: var(--text-muted); font-size: 12px;">
|
|
3781
|
+
<p>${emptyMsg}</p>
|
|
3782
|
+
<p style="margin-top: 8px; font-size: 11px;">${emptyHint}</p>
|
|
3783
|
+
</div>
|
|
3784
|
+
${outsideHtml}`;
|
|
3785
|
+
return;
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3701
3788
|
// Zen draws one card and its panel, so it returns before the grouping below — leaving it in
|
|
3702
3789
|
// that chain would build the whole list only to discard it.
|
|
3703
3790
|
if (zenMode) {
|
|
@@ -3720,20 +3807,6 @@ function renderSessions() {
|
|
|
3720
3807
|
const groupPinned = store.getItem('groupPinnedSessions') !== 'false';
|
|
3721
3808
|
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
|
|
3722
3809
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
3723
|
-
const countHtml = (arr) => {
|
|
3724
|
-
const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
3725
|
-
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>`;
|
|
3726
|
-
};
|
|
3727
|
-
// `ungroup` marks the label as the drop target for pulling an item out of a named group.
|
|
3728
|
-
const sectionHtml = (key, text, ungroup, body, empty = false) => {
|
|
3729
|
-
const collapsed = collapsedProjectGroups.has(key);
|
|
3730
|
-
const escPath = escapeHtml(key);
|
|
3731
|
-
const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
|
|
3732
|
-
const emptyCls = empty ? ' sg-section-empty' : '';
|
|
3733
|
-
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>
|
|
3734
|
-
<div class="sg-section-body${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}">${body}</div>`;
|
|
3735
|
-
};
|
|
3736
|
-
|
|
3737
3810
|
const renderGroupSessions = (sessions, pinKey) => {
|
|
3738
3811
|
if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
|
|
3739
3812
|
const gIdlePinned = sessions.filter(isInPinnedGroup);
|
|
@@ -3953,13 +4026,13 @@ function renderSessions() {
|
|
|
3953
4026
|
const noProjects = sortedGroups.length === 0 && ungrouped.length === 0;
|
|
3954
4027
|
html += sectioned ? sectionHtml(SECTION_PROJECTS, 'Projects', true, projectsHtml, noProjects) : projectsHtml;
|
|
3955
4028
|
|
|
3956
|
-
sessionsList.innerHTML = html;
|
|
4029
|
+
sessionsList.innerHTML = html + outsideHtml;
|
|
3957
4030
|
} else {
|
|
3958
4031
|
const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
|
|
3959
4032
|
const restHtml =
|
|
3960
4033
|
sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
|
|
3961
4034
|
const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
|
|
3962
|
-
sessionsList.innerHTML = html + sgSectionHtml(true) + tail;
|
|
4035
|
+
sessionsList.innerHTML = html + sgSectionHtml(true) + tail + outsideHtml;
|
|
3963
4036
|
}
|
|
3964
4037
|
|
|
3965
4038
|
const navItems = getNavigableItems();
|
|
@@ -5017,10 +5090,12 @@ function sgOpenMenu(x, y, kind, ref) {
|
|
|
5017
5090
|
menu.id = 'sg-menu';
|
|
5018
5091
|
menu.className = 'sg-menu';
|
|
5019
5092
|
menu.setAttribute('role', 'menu');
|
|
5020
|
-
|
|
5093
|
+
const canFork = kind === 'session' && !!forkableSession(ref);
|
|
5094
|
+
menu.setAttribute('aria-label', canFork ? 'Session actions' : `Move ${label} to group`);
|
|
5021
5095
|
menu.dataset.sgKind = kind;
|
|
5022
5096
|
menu.dataset.sgRef = ref;
|
|
5023
5097
|
menu.innerHTML = `
|
|
5098
|
+
${canFork ? '<div class="sg-menu-label" aria-hidden="true">Session</div><button class="sg-menu-item" role="menuitem" data-sg-action="fork" title="claude --resume --fork-session">Fork</button>' : ''}
|
|
5024
5099
|
<div class="sg-menu-label" aria-hidden="true">Move ${label} to group</div>
|
|
5025
5100
|
${rows}
|
|
5026
5101
|
<button class="sg-menu-item" role="menuitem" data-sg-move="__new__">+ New group…</button>
|
|
@@ -5068,6 +5143,7 @@ document.addEventListener('click', (e) => {
|
|
|
5068
5143
|
const { sgKind, sgRef } = menu.dataset;
|
|
5069
5144
|
const move = item.dataset.sgMove;
|
|
5070
5145
|
sgCloseMenu();
|
|
5146
|
+
if (item.dataset.sgAction === 'fork') return forkSession(sgRef);
|
|
5071
5147
|
if (move === '__none__') {
|
|
5072
5148
|
sgDetach(sgKind, sgRef);
|
|
5073
5149
|
persistSessionGroups();
|
|
@@ -5311,18 +5387,16 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
5311
5387
|
}
|
|
5312
5388
|
|
|
5313
5389
|
// Drops every collapse key that could hide this session: its project, a named group wrapping
|
|
5314
|
-
// the project or the session itself, the pinned sub-section only when the card renders inside
|
|
5315
|
-
// it (pinned, not sticky, idle — see renderGroupSessions), and
|
|
5316
|
-
//
|
|
5390
|
+
// the project or the session itself and the project block it renders under there, the pinned sub-section only when the card renders inside
|
|
5391
|
+
// it (pinned, not sticky, idle — see renderGroupSessions), and the section header that holds it:
|
|
5392
|
+
// Groups for a grouped session, else Projects or Sessions, which depends on the view. Returns
|
|
5393
|
+
// whether anything changed.
|
|
5317
5394
|
function uncollapseFor(session) {
|
|
5318
5395
|
const group = sgGroupForSession(session);
|
|
5319
5396
|
const keys = [
|
|
5320
5397
|
sessionProjectKey(session) || '__ungrouped__',
|
|
5321
5398
|
...pinnedCollapseKeys(session),
|
|
5322
|
-
group
|
|
5323
|
-
SECTION_GROUPS,
|
|
5324
|
-
SECTION_PROJECTS,
|
|
5325
|
-
SECTION_SESSIONS,
|
|
5399
|
+
...(group ? [sgKey(group.id), sgHostOf(group, session), SECTION_GROUPS] : [SECTION_PROJECTS, SECTION_SESSIONS]),
|
|
5326
5400
|
];
|
|
5327
5401
|
let changed = false;
|
|
5328
5402
|
for (const key of keys) {
|
|
@@ -5917,9 +5991,9 @@ const SHORTCUT_TABS = [
|
|
|
5917
5991
|
{
|
|
5918
5992
|
title: 'Sessions',
|
|
5919
5993
|
rows: [
|
|
5920
|
-
{ keys: ['Ctrl', 'Alt', 'N'], combo: true, label: 'New session' },
|
|
5921
|
-
{ keys: ['Ctrl', 'Alt', 'R'], combo: true, label: 'Resume session (claude -r)' },
|
|
5922
|
-
{ keys: ['Ctrl', 'Alt', 'S'], combo: true, label: 'Swap to previous session' },
|
|
5994
|
+
{ keys: ['Ctrl', 'Alt', 'N'], combo: true, hubMod: true, label: 'New session' },
|
|
5995
|
+
{ keys: ['Ctrl', 'Alt', 'R'], combo: true, hubMod: true, label: 'Resume session (claude -r)' },
|
|
5996
|
+
{ keys: ['Ctrl', 'Alt', 'S'], combo: true, hubMod: true, label: 'Swap to previous session' },
|
|
5923
5997
|
],
|
|
5924
5998
|
},
|
|
5925
5999
|
],
|
|
@@ -5949,17 +6023,57 @@ const SHORTCUT_TABS = [
|
|
|
5949
6023
|
{ keys: ['M'], label: 'Jump to marketplace' },
|
|
5950
6024
|
{ keys: ['$'], label: 'Jump to cost' },
|
|
5951
6025
|
{ keys: ['Ctrl', 'M'], combo: true, label: 'Jump to memory' },
|
|
5952
|
-
{
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
6026
|
+
{
|
|
6027
|
+
keys: ['Ctrl', 'Alt', '←/→'],
|
|
6028
|
+
action: ['hub.prevApp', 'hub.nextApp'],
|
|
6029
|
+
combo: true,
|
|
6030
|
+
hubMod: true,
|
|
6031
|
+
label: 'Previous / next hub app',
|
|
6032
|
+
},
|
|
6033
|
+
{
|
|
6034
|
+
keys: ['Alt', '1…9'],
|
|
6035
|
+
action: 'hub.appByNumber',
|
|
6036
|
+
combo: true,
|
|
6037
|
+
hubMod: true,
|
|
6038
|
+
label: 'Jump to hub app by number',
|
|
6039
|
+
},
|
|
6040
|
+
{
|
|
6041
|
+
keys: ['Ctrl', 'Alt', 'P'],
|
|
6042
|
+
action: 'hub.projectPicker',
|
|
6043
|
+
combo: true,
|
|
6044
|
+
hubMod: true,
|
|
6045
|
+
label: 'Project picker',
|
|
6046
|
+
},
|
|
6047
|
+
{
|
|
6048
|
+
keys: ['Ctrl', 'Alt', 'W'],
|
|
6049
|
+
action: 'hub.configDirPicker',
|
|
6050
|
+
combo: true,
|
|
6051
|
+
hubMod: true,
|
|
6052
|
+
label: 'Config dir picker',
|
|
6053
|
+
},
|
|
6054
|
+
{ keys: ['Ctrl', 'Alt', 'A'], action: 'hub.appLauncher', combo: true, hubMod: true, label: 'App launcher' },
|
|
5957
6055
|
],
|
|
5958
6056
|
},
|
|
5959
6057
|
],
|
|
5960
6058
|
},
|
|
5961
6059
|
];
|
|
5962
6060
|
|
|
6061
|
+
// macOS names Control, Option and Shift by their symbols.
|
|
6062
|
+
const IS_MAC = /^Mac/i.test(navigator.userAgentData?.platform || navigator.platform || '');
|
|
6063
|
+
const MAC_KEYS = { Ctrl: '⌃', Alt: '⌥', Shift: '⇧' };
|
|
6064
|
+
if (IS_MAC) document.getElementById('new-session-btn')?.setAttribute('title', 'New session (⌃⌥N)');
|
|
6065
|
+
|
|
6066
|
+
// The keys of a help row as they read on this system. The hub's modifier is Ctrl+Alt, and on
|
|
6067
|
+
// macOS Control+Option for the tab numbers too (Windows and Linux use bare Alt for those). A hub
|
|
6068
|
+
// row shows the keys the hub reports, which the user can change; row.keys until it answers.
|
|
6069
|
+
function helpKeys(row, mac = IS_MAC, keyLabel = hub.keyLabel) {
|
|
6070
|
+
const live = row.action && keyLabel(row.action);
|
|
6071
|
+
if (live) return live;
|
|
6072
|
+
if (!mac) return row.keys;
|
|
6073
|
+
if (row.hubMod) return ['⌃', '⌥', ...row.keys.filter((k) => k !== 'Ctrl' && k !== 'Alt')];
|
|
6074
|
+
return row.keys.map((k) => MAC_KEYS[k] || k);
|
|
6075
|
+
}
|
|
6076
|
+
|
|
5963
6077
|
// Interleaves each pair's rows left-then-right so CSS grid auto-placement lands
|
|
5964
6078
|
// them on shared row tracks (see .shortcuts in style.css).
|
|
5965
6079
|
function buildHelpShortcuts(groups) {
|
|
@@ -5975,9 +6089,10 @@ function buildHelpShortcuts(groups) {
|
|
|
5975
6089
|
for (let i = 0; i < Math.max(...sides.map(([g]) => g.rows.length)); i++) {
|
|
5976
6090
|
for (const [group, side] of sides) {
|
|
5977
6091
|
const row = group.rows[i];
|
|
5978
|
-
|
|
6092
|
+
const parts = row && helpKeys(row);
|
|
6093
|
+
if (!parts?.length) continue;
|
|
5979
6094
|
const sep = row.combo ? '<span class="sc-plus">+</span>' : '<span class="sc-or">/</span>';
|
|
5980
|
-
const keys =
|
|
6095
|
+
const keys = parts.map((k) => `<kbd>${escapeHtml(k)}</kbd>`).join(sep);
|
|
5981
6096
|
const label = escapeHtml(row.label);
|
|
5982
6097
|
cells.push(`<dt class="${escapeHtml(side)}">${keys}</dt><dd class="${escapeHtml(side)}">${label}</dd>`);
|
|
5983
6098
|
}
|
|
@@ -5996,7 +6111,10 @@ const helpTabs = () => SHORTCUT_TABS.filter((t) => hub.inHub || !t.hub);
|
|
|
5996
6111
|
function filteredShortcutGroups(query) {
|
|
5997
6112
|
return helpTabs()
|
|
5998
6113
|
.flatMap((t) => t.groups)
|
|
5999
|
-
.map((g) => ({
|
|
6114
|
+
.map((g) => ({
|
|
6115
|
+
title: g.title,
|
|
6116
|
+
rows: g.rows.filter((r) => fuzzyMatch(`${r.label} ${helpKeys(r).join(' ')}`, query)),
|
|
6117
|
+
}))
|
|
6000
6118
|
.filter((g) => g.rows.length);
|
|
6001
6119
|
}
|
|
6002
6120
|
|
|
@@ -7630,9 +7748,10 @@ function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector
|
|
|
7630
7748
|
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta']
|
|
7631
7749
|
.filter(Boolean)
|
|
7632
7750
|
.join('+');
|
|
7633
|
-
return [
|
|
7634
|
-
|
|
7635
|
-
|
|
7751
|
+
return [
|
|
7752
|
+
e.code && !e.getModifierState?.('AltGraph') && `${mods}|c:${e.code}`,
|
|
7753
|
+
typeof e.key === 'string' && `${mods}|k:${e.key.toLowerCase()}`,
|
|
7754
|
+
].filter(Boolean);
|
|
7636
7755
|
};
|
|
7637
7756
|
let claims = new Set();
|
|
7638
7757
|
let forward = new Set();
|
|
@@ -11008,38 +11127,9 @@ let spRenderedQuery = null;
|
|
|
11008
11127
|
// Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
|
|
11009
11128
|
const spPeekCache = new Map();
|
|
11010
11129
|
|
|
11011
|
-
|
|
11012
|
-
|
|
11013
|
-
|
|
11014
|
-
let spSearchTimer = null;
|
|
11015
|
-
|
|
11016
|
-
function spSearchKey(query) {
|
|
11017
|
-
return query.length >= 3 ? query.toLowerCase() : null;
|
|
11018
|
-
}
|
|
11019
|
-
|
|
11020
|
-
function spScheduleSearch(key) {
|
|
11021
|
-
clearTimeout(spSearchTimer);
|
|
11022
|
-
spSearchTimer = setTimeout(async () => {
|
|
11023
|
-
spSearch.set(key, 'pending');
|
|
11024
|
-
let ids = [];
|
|
11025
|
-
try {
|
|
11026
|
-
const r = await fetch(`/api/sessions/search?q=${encodeURIComponent(key)}`);
|
|
11027
|
-
if (r.ok) ids = await r.json();
|
|
11028
|
-
} catch (_) {}
|
|
11029
|
-
spSearch.set(key, ids.length ? await fetchSessionsByIds(ids) : []);
|
|
11030
|
-
renderSessionPicker();
|
|
11031
|
-
}, 100);
|
|
11032
|
-
}
|
|
11033
|
-
|
|
11034
|
-
// The server rows not already matched in the sidebar list, and whether a search is still out.
|
|
11035
|
-
function spSearchRows(key, localRows) {
|
|
11036
|
-
if (!key) return { rows: [], pending: false };
|
|
11037
|
-
const found = spSearch.get(key);
|
|
11038
|
-
if (!found) spScheduleSearch(key);
|
|
11039
|
-
if (!Array.isArray(found)) return { rows: [], pending: true };
|
|
11040
|
-
const seen = new Set(localRows.map((s) => s.id));
|
|
11041
|
-
return { rows: found.filter((s) => !seen.has(s.id)), pending: false };
|
|
11042
|
-
}
|
|
11130
|
+
const spSearch = sessionSearch(() => {
|
|
11131
|
+
if (document.getElementById('session-picker-modal').classList.contains('visible')) renderSessionPicker();
|
|
11132
|
+
});
|
|
11043
11133
|
|
|
11044
11134
|
function openSessionPicker() {
|
|
11045
11135
|
spFromTerminal = terminalPaneFocused();
|
|
@@ -11047,7 +11137,6 @@ function openSessionPicker() {
|
|
|
11047
11137
|
input.value = '';
|
|
11048
11138
|
spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
|
|
11049
11139
|
spPeekCache.clear();
|
|
11050
|
-
spSearch.clear();
|
|
11051
11140
|
spRenderedQuery = null;
|
|
11052
11141
|
document.getElementById('session-picker-modal').classList.add('visible');
|
|
11053
11142
|
renderSessionPicker();
|
|
@@ -11170,15 +11259,14 @@ function renderSessionPicker() {
|
|
|
11170
11259
|
// Search rows that arrive for the same query append below, so the cursor stays on its row.
|
|
11171
11260
|
const selectedId = query === spRenderedQuery ? spRows[spIdx]?.id : null;
|
|
11172
11261
|
spRenderedQuery = query;
|
|
11173
|
-
const key = spSearchKey(query);
|
|
11174
11262
|
const local = spSource.filter((s) => spMatches(s, query));
|
|
11175
|
-
const found =
|
|
11263
|
+
const found = spSearch.rows(query, local);
|
|
11176
11264
|
spRows = [...local, ...found.rows];
|
|
11177
11265
|
if (!spRows.length) {
|
|
11178
11266
|
spIdx = -1;
|
|
11179
11267
|
const empty = found.pending
|
|
11180
11268
|
? 'Searching all sessions…'
|
|
11181
|
-
: spSource.length ||
|
|
11269
|
+
: spSource.length || query.length >= SESSION_SEARCH_MIN
|
|
11182
11270
|
? 'No session matches'
|
|
11183
11271
|
: 'No sessions in the current sidebar filter';
|
|
11184
11272
|
list.innerHTML = `<div class="sp-empty">${empty}</div>`;
|
|
@@ -12171,9 +12259,14 @@ function togglePaneSplit() {
|
|
|
12171
12259
|
return true;
|
|
12172
12260
|
}
|
|
12173
12261
|
|
|
12262
|
+
function shownPaneSplit() {
|
|
12263
|
+
const sid = paneSessionId();
|
|
12264
|
+
return sid && !wantsTerminal() && getPaneSplit(sid);
|
|
12265
|
+
}
|
|
12266
|
+
|
|
12174
12267
|
function focusPaneSide(side) {
|
|
12175
12268
|
const sid = paneSessionId();
|
|
12176
|
-
const split =
|
|
12269
|
+
const split = shownPaneSplit();
|
|
12177
12270
|
if (!split) return false;
|
|
12178
12271
|
if (getActivePaneId(sid) !== split[side]) {
|
|
12179
12272
|
setActivePaneId(sid, split[side]);
|
|
@@ -12363,6 +12456,20 @@ function updatePaneFades() {
|
|
|
12363
12456
|
paneTabs.classList.toggle('fade-r', scrollLeft + clientWidth < scrollWidth - 1);
|
|
12364
12457
|
}
|
|
12365
12458
|
|
|
12459
|
+
// A URL pane's page is on another origin, so its key presses never reach the board: the zoom
|
|
12460
|
+
// keys zoom the whole window and the pane keys do nothing. The bar zooms without keys and
|
|
12461
|
+
// says when the page has the keyboard; a click on it gives the keys back.
|
|
12462
|
+
function paneUrlBarHtml() {
|
|
12463
|
+
return `<div class="pane-url-bar"><button class="icon-btn pane-keys" title="The page has keyboard focus. Click to turn the board shortcuts back on">Board shortcuts off · click to turn on</button><button class="icon-btn" data-pane-zoom="-0.1" title="Smaller" aria-label="Smaller">−</button><button class="icon-btn pane-zoom-val" data-pane-zoom="0" title="Reset size">${modalZoomLabel()}</button><button class="icon-btn" data-pane-zoom="0.1" title="Larger" aria-label="Larger">+</button></div>`;
|
|
12464
|
+
}
|
|
12465
|
+
|
|
12466
|
+
function markPaneFrameFocus() {
|
|
12467
|
+
const el = document.activeElement;
|
|
12468
|
+
const view = el?.classList.contains('pane-url-frame') ? el.parentElement : null;
|
|
12469
|
+
for (const v of paneViews.querySelectorAll('.pane-view.frame-focus')) v.classList.remove('frame-focus');
|
|
12470
|
+
view?.classList.add('frame-focus');
|
|
12471
|
+
}
|
|
12472
|
+
|
|
12366
12473
|
function paneCardHtml(title, text, button) {
|
|
12367
12474
|
return `<div class="pane-card"><div class="pane-card-box">${PANE_ICONS.warning}<h3>${escapeHtml(title)}</h3><p>${escapeHtml(text)}</p><button class="btn btn-primary" data-pane-open>${escapeHtml(button)}</button></div></div>`;
|
|
12368
12475
|
}
|
|
@@ -12412,12 +12519,13 @@ async function loadPaneView(sid, pane, view, scroll) {
|
|
|
12412
12519
|
}
|
|
12413
12520
|
if (pane.kind === 'url') {
|
|
12414
12521
|
const frame = document.createElement('iframe');
|
|
12415
|
-
frame.className = 'pane-frame modal-zoomable';
|
|
12522
|
+
frame.className = 'pane-frame pane-url-frame modal-zoomable';
|
|
12416
12523
|
frame.setAttribute('sandbox', PANE_URL_SANDBOX);
|
|
12417
12524
|
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
12418
12525
|
frame.title = pane.title;
|
|
12419
12526
|
frame.src = pane.target;
|
|
12420
12527
|
view.appendChild(frame);
|
|
12528
|
+
view.insertAdjacentHTML('beforeend', paneUrlBarHtml());
|
|
12421
12529
|
// Framed at once; a refusal found by the probe swaps the frame for the card.
|
|
12422
12530
|
if ((await probePaneFraming(sid, pane)) === false) {
|
|
12423
12531
|
frame.remove();
|
|
@@ -12907,8 +13015,42 @@ function focusSideOf(el) {
|
|
|
12907
13015
|
// That blur is also the only sign that a click in a split moved into a pane's frame.
|
|
12908
13016
|
window.addEventListener('blur', () => {
|
|
12909
13017
|
closePaneMenu();
|
|
12910
|
-
setTimeout(() =>
|
|
13018
|
+
setTimeout(() => {
|
|
13019
|
+
syncPaneFrameFocus();
|
|
13020
|
+
watchPaneFrameFocus();
|
|
13021
|
+
});
|
|
13022
|
+
});
|
|
13023
|
+
window.addEventListener('focus', () => {
|
|
13024
|
+
clearInterval(paneFrameFocusPoll);
|
|
13025
|
+
markPaneFrameFocus();
|
|
12911
13026
|
});
|
|
13027
|
+
|
|
13028
|
+
function syncPaneFrameFocus() {
|
|
13029
|
+
focusSideOf(document.activeElement?.closest?.('.pane-view.on'));
|
|
13030
|
+
markPaneFrameFocus();
|
|
13031
|
+
}
|
|
13032
|
+
|
|
13033
|
+
function splitPaneFrameFocused() {
|
|
13034
|
+
const el = document.activeElement;
|
|
13035
|
+
return el?.tagName === 'IFRAME' && paneViews.contains(el) && !!shownPaneSplit();
|
|
13036
|
+
}
|
|
13037
|
+
|
|
13038
|
+
// A click from one frame into another fires no event in this document, so while a split's frame
|
|
13039
|
+
// has focus the board checks which one it is: one pointer compare four times a second.
|
|
13040
|
+
let paneFrameFocusPoll = 0;
|
|
13041
|
+
function watchPaneFrameFocus() {
|
|
13042
|
+
clearInterval(paneFrameFocusPoll);
|
|
13043
|
+
if (!splitPaneFrameFocused()) return;
|
|
13044
|
+
let last = document.activeElement;
|
|
13045
|
+
paneFrameFocusPoll = setInterval(() => {
|
|
13046
|
+
const el = document.activeElement;
|
|
13047
|
+
if (!splitPaneFrameFocused()) clearInterval(paneFrameFocusPoll);
|
|
13048
|
+
else if (el !== last) {
|
|
13049
|
+
last = el;
|
|
13050
|
+
syncPaneFrameFocus();
|
|
13051
|
+
}
|
|
13052
|
+
}, 250);
|
|
13053
|
+
}
|
|
12912
13054
|
paneSplitBox.addEventListener(
|
|
12913
13055
|
'pointerdown',
|
|
12914
13056
|
(e) => focusSideOf(paneLog.contains(e.target) ? paneBoard : e.target.closest('.side-l, .side-r')),
|
|
@@ -13024,6 +13166,8 @@ function applySplitRatio() {
|
|
|
13024
13166
|
|
|
13025
13167
|
paneViews.addEventListener('click', (e) => {
|
|
13026
13168
|
if (e.target.closest('[data-pane-open]')) openPaneExternally();
|
|
13169
|
+
const zoom = e.target.closest('[data-pane-zoom]');
|
|
13170
|
+
if (zoom) adjustModalZoom(Number(zoom.dataset.paneZoom));
|
|
13027
13171
|
});
|
|
13028
13172
|
bindPreviewRelativeLinks(paneViews, (a) => a.closest('.pane-doc')?.dataset.path);
|
|
13029
13173
|
paneTabs.addEventListener('scroll', updatePaneFades, { passive: true });
|
|
@@ -13193,6 +13337,12 @@ function terminalPaneFocused() {
|
|
|
13193
13337
|
return document.getElementById('terminal-pane').contains(document.activeElement);
|
|
13194
13338
|
}
|
|
13195
13339
|
|
|
13340
|
+
// Kanban's own session keys (new, resume, swap) use the hub's modifier, so the hub leaves them alone.
|
|
13341
|
+
// AltGr reaches the page as Ctrl+Alt; when it types a character (Polish AltGr+S is 'ś'), the press is text.
|
|
13342
|
+
function hubModDown(e) {
|
|
13343
|
+
return e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey && !e.getModifierState?.('AltGraph');
|
|
13344
|
+
}
|
|
13345
|
+
|
|
13196
13346
|
function inPageField(e) {
|
|
13197
13347
|
const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
|
|
13198
13348
|
return !!field && !document.getElementById('terminal-pane').contains(field);
|
|
@@ -13200,7 +13350,7 @@ function inPageField(e) {
|
|
|
13200
13350
|
|
|
13201
13351
|
// `probe` asks which keys the terminal frame hands back, and the frame sees keys only while it has focus.
|
|
13202
13352
|
function terminalShortcut(e, probe = false) {
|
|
13203
|
-
const ctrlAlt = e
|
|
13353
|
+
const ctrlAlt = hubModDown(e);
|
|
13204
13354
|
const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
|
|
13205
13355
|
// Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal.
|
|
13206
13356
|
const zoom = e.key === '_' ? undefined : zoomDelta(e);
|
|
@@ -13308,8 +13458,10 @@ function syncTerminal() {
|
|
|
13308
13458
|
}
|
|
13309
13459
|
|
|
13310
13460
|
// Only paths the user drives call this; restores, deep links and refreshes call fetchTasks, so they never take focus.
|
|
13311
|
-
function openSession(sessionId) {
|
|
13461
|
+
async function openSession(sessionId) {
|
|
13312
13462
|
setSessionDismissed(sessionId, false);
|
|
13463
|
+
// Awaited only when missing, so opening a loaded session stays synchronous up to fetchTasks.
|
|
13464
|
+
if (!sessions.some((s) => s.id === sessionId)) await ensureSessionLoaded(sessionId);
|
|
13313
13465
|
const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
|
|
13314
13466
|
if (!alreadyOpen) termState.openedByUser = sessionId;
|
|
13315
13467
|
else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
|
|
@@ -13808,6 +13960,8 @@ async function openTerminal(sessionId, mode, attempt = 0) {
|
|
|
13808
13960
|
worktree: spec.worktree,
|
|
13809
13961
|
model: spec.model,
|
|
13810
13962
|
prompt: spec.prompt,
|
|
13963
|
+
edit: spec.edit,
|
|
13964
|
+
forkOf: spec.forkOf,
|
|
13811
13965
|
}),
|
|
13812
13966
|
},
|
|
13813
13967
|
});
|
|
@@ -13899,7 +14053,6 @@ function onTerminalMessage(sessionId, msg, tail = '') {
|
|
|
13899
14053
|
'Resuming it here too makes both processes write to the same transcript.',
|
|
13900
14054
|
[
|
|
13901
14055
|
['resume', 'Resume anyway'],
|
|
13902
|
-
['fork', 'Fork'],
|
|
13903
14056
|
['shell', 'Shell only'],
|
|
13904
14057
|
],
|
|
13905
14058
|
);
|
|
@@ -13924,7 +14077,6 @@ function onTerminalMessage(sessionId, msg, tail = '') {
|
|
|
13924
14077
|
]
|
|
13925
14078
|
: [
|
|
13926
14079
|
['resume', 'Resume'],
|
|
13927
|
-
['fork', 'Fork'],
|
|
13928
14080
|
['shell', 'Shell'],
|
|
13929
14081
|
],
|
|
13930
14082
|
clean ? '' : tail,
|
|
@@ -14097,20 +14249,28 @@ const newSpecs = new Map();
|
|
|
14097
14249
|
const dispatchSpecs = new Map();
|
|
14098
14250
|
const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false };
|
|
14099
14251
|
const NEW_SESSION_STAY_KEY = 'new-session-stay';
|
|
14252
|
+
const NEW_SESSION_EDIT_KEY = 'new-session-edit';
|
|
14100
14253
|
|
|
14101
14254
|
const PLACEHOLDER_NAMES = { pick: 'Resume session', dispatch: 'Started session', new: 'New session' };
|
|
14102
14255
|
const PLACEHOLDER_HINTS = {
|
|
14103
14256
|
pick: 'pick a session to resume',
|
|
14104
14257
|
dispatch: 'starting',
|
|
14105
14258
|
new: 'waiting for your first message',
|
|
14259
|
+
edit: 'press Enter in the terminal to start',
|
|
14106
14260
|
};
|
|
14107
14261
|
|
|
14262
|
+
// The parent may be outside the loaded list, so its short id stands in for its name.
|
|
14263
|
+
function forkLabel(parentId) {
|
|
14264
|
+
return `Fork of ${sessionDisplayName(sessions.find((s) => s.id === parentId) || { id: parentId })}`;
|
|
14265
|
+
}
|
|
14266
|
+
|
|
14108
14267
|
function placeholderSession(id, spec) {
|
|
14109
14268
|
return {
|
|
14110
14269
|
id,
|
|
14111
14270
|
placeholder: true,
|
|
14112
14271
|
mode: spec.mode,
|
|
14113
|
-
|
|
14272
|
+
edit: !!spec.edit,
|
|
14273
|
+
name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
14114
14274
|
dispatchGroup: spec.group || undefined,
|
|
14115
14275
|
dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
|
|
14116
14276
|
project: spec.cwd,
|
|
@@ -14189,7 +14349,7 @@ function renderPlaceholderCard(session) {
|
|
|
14189
14349
|
<button onclick="fetchTasks('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
|
|
14190
14350
|
<div class="session-name">${escapeHtml(session.name)}</div>
|
|
14191
14351
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
14192
|
-
<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.mode] || PLACEHOLDER_HINTS.new}</div>
|
|
14352
|
+
<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>
|
|
14193
14353
|
</button>
|
|
14194
14354
|
`;
|
|
14195
14355
|
}
|
|
@@ -14203,7 +14363,15 @@ async function restorePendingSessions() {
|
|
|
14203
14363
|
try {
|
|
14204
14364
|
for (const t of await terminals) {
|
|
14205
14365
|
if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
|
|
14206
|
-
newSpecs.set(t.id, {
|
|
14366
|
+
newSpecs.set(t.id, {
|
|
14367
|
+
cwd: t.cwd,
|
|
14368
|
+
name: t.name,
|
|
14369
|
+
worktree: t.worktree,
|
|
14370
|
+
mode: t.mode,
|
|
14371
|
+
edit: t.edit,
|
|
14372
|
+
forkOf: t.forkOf,
|
|
14373
|
+
startedAt: t.startedAt,
|
|
14374
|
+
});
|
|
14207
14375
|
}
|
|
14208
14376
|
} catch (_) {}
|
|
14209
14377
|
}
|
|
@@ -14241,6 +14409,7 @@ function openNewSession(folder, resume = false) {
|
|
|
14241
14409
|
document.getElementById('ns-wt').checked = false;
|
|
14242
14410
|
document.getElementById('ns-model').value = '';
|
|
14243
14411
|
document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
|
|
14412
|
+
document.getElementById('ns-edit').checked = store.getItem(NEW_SESSION_EDIT_KEY) === 'true';
|
|
14244
14413
|
setNewSessionError('');
|
|
14245
14414
|
const current = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
|
|
14246
14415
|
setNewSessionFolder(folder || current || '');
|
|
@@ -14340,12 +14509,15 @@ function folderListOpen() {
|
|
|
14340
14509
|
function newSessionValues() {
|
|
14341
14510
|
const wtOn = document.getElementById('ns-wt').checked;
|
|
14342
14511
|
const wtName = document.getElementById('ns-wt-name').value.trim();
|
|
14512
|
+
const edit = !ns.resume && document.getElementById('ns-edit').checked;
|
|
14343
14513
|
return {
|
|
14344
14514
|
cwd: ns.folder,
|
|
14345
14515
|
name: document.getElementById('ns-name').value.trim() || null,
|
|
14346
14516
|
worktree: wtOn ? wtName || true : false,
|
|
14347
14517
|
model: document.getElementById('ns-model').value || null,
|
|
14348
14518
|
prompt: document.getElementById('ns-prompt').value.trim() || null,
|
|
14519
|
+
edit,
|
|
14520
|
+
stay: !ns.resume && !edit && document.getElementById('ns-stay').checked,
|
|
14349
14521
|
};
|
|
14350
14522
|
}
|
|
14351
14523
|
|
|
@@ -14371,11 +14543,12 @@ function renderNewSessionForm() {
|
|
|
14371
14543
|
const hint = document.getElementById('ns-wt-hint');
|
|
14372
14544
|
hint.hidden = !wtOn;
|
|
14373
14545
|
hint.textContent = `.claude/worktrees/${typeof v.worktree === 'string' ? v.worktree : '<name chosen by Claude>'}`;
|
|
14546
|
+
document.getElementById('ns-stay').disabled = v.edit;
|
|
14374
14547
|
const problem = newSessionProblem(v);
|
|
14375
14548
|
setNewSessionError(problem);
|
|
14376
14549
|
const start = document.getElementById('ns-start');
|
|
14377
14550
|
start.disabled = !v.cwd || !!problem || ns.busy;
|
|
14378
|
-
const verb = ns.resume ? 'Resume' : 'Start';
|
|
14551
|
+
const verb = ns.resume ? 'Resume' : v.edit ? 'Open' : 'Start';
|
|
14379
14552
|
start.textContent = v.cwd ? `${verb} in ${pathBasename(v.cwd)}` : verb;
|
|
14380
14553
|
}
|
|
14381
14554
|
|
|
@@ -14424,12 +14597,30 @@ function startNewSession() {
|
|
|
14424
14597
|
const id = crypto.randomUUID();
|
|
14425
14598
|
newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
|
|
14426
14599
|
setTerminalMode(id, true);
|
|
14427
|
-
if (
|
|
14428
|
-
sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
|
|
14600
|
+
if (v.stay) return startNewSessionInBackground(id, v);
|
|
14429
14601
|
closeNewSession();
|
|
14602
|
+
openPlaceholder(id);
|
|
14603
|
+
}
|
|
14604
|
+
|
|
14605
|
+
function openPlaceholder(id) {
|
|
14606
|
+
sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
|
|
14430
14607
|
fetchTasks(id).then(() => sessionsList.querySelector('.session-item.active')?.scrollIntoView({ block: 'nearest' }));
|
|
14431
14608
|
}
|
|
14432
14609
|
|
|
14610
|
+
function forkableSession(id) {
|
|
14611
|
+
return terminalAvailable() ? sessions.find((s) => s.id === id && !s.placeholder) : undefined;
|
|
14612
|
+
}
|
|
14613
|
+
|
|
14614
|
+
// The fork gets its own id up front, so it runs as a new session and needs no rekey.
|
|
14615
|
+
function forkSession(parentId) {
|
|
14616
|
+
const parent = forkableSession(parentId);
|
|
14617
|
+
if (!parent) return;
|
|
14618
|
+
const id = crypto.randomUUID();
|
|
14619
|
+
newSpecs.set(id, { cwd: parent.project, forkOf: parentId, mode: 'new', startedAt: Date.now() });
|
|
14620
|
+
setTerminalMode(id, true);
|
|
14621
|
+
openPlaceholder(id);
|
|
14622
|
+
}
|
|
14623
|
+
|
|
14433
14624
|
async function startNewSessionInBackground(id, v) {
|
|
14434
14625
|
refreshPlaceholders();
|
|
14435
14626
|
setNewSessionBusy(true);
|
|
@@ -14528,6 +14719,10 @@ function initNewSession() {
|
|
|
14528
14719
|
document
|
|
14529
14720
|
.getElementById('ns-stay')
|
|
14530
14721
|
.addEventListener('change', (e) => store.setItem(NEW_SESSION_STAY_KEY, String(e.target.checked)));
|
|
14722
|
+
document.getElementById('ns-edit').addEventListener('change', (e) => {
|
|
14723
|
+
store.setItem(NEW_SESSION_EDIT_KEY, String(e.target.checked));
|
|
14724
|
+
renderNewSessionForm();
|
|
14725
|
+
});
|
|
14531
14726
|
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
|
|
14532
14727
|
document.getElementById(id).addEventListener('input', renderNewSessionForm);
|
|
14533
14728
|
}
|