claude-code-kanban 7.7.0 → 7.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/cli.js +23 -49
- package/hub-app.json +2 -2
- package/install.js +11 -9
- package/lib/live-sessions.js +17 -2
- package/lib/retention.js +10 -1
- package/lib/terminal-client.js +6 -0
- package/lib/terminal-host.js +8 -1
- package/lib/terminal.js +98 -22
- package/lib/user-groups.js +86 -66
- package/package.json +2 -1
- package/public/app.js +716 -255
- package/public/group-tree.js +21 -0
- package/public/index.html +35 -14
- package/public/style.css +147 -10
- package/public/sw.js +1 -1
- package/public/vendor/claude-hub-sdk.js +1 -1
- package/server.js +80 -44
- package/skill-guides/dispatch.md +1 -1
- package/lib/dispatch-groups.js +0 -88
package/public/app.js
CHANGED
|
@@ -94,6 +94,8 @@ let sessionsHasMore = false;
|
|
|
94
94
|
// URL, so a query string would not survive a hub reload.
|
|
95
95
|
const ZEN_KEY = 'zenMode';
|
|
96
96
|
let zenMode = store.getItem(ZEN_KEY) === 'true';
|
|
97
|
+
// Group zen: the sidebar shows one group's subtree. A view of the moment, so it is not persisted.
|
|
98
|
+
let zenGroup = null;
|
|
97
99
|
let filterProject = FILTER_DEFAULTS.project; // null = all, '__recent__' = last 24h, or project path
|
|
98
100
|
let recentProjects = new Set();
|
|
99
101
|
let projectsCacheDirty = true;
|
|
@@ -109,8 +111,6 @@ const SESSION_ORDER_KEY = 'session-order';
|
|
|
109
111
|
let stableGroupOrder = readStoredList(PROJECT_ORDER_KEY);
|
|
110
112
|
let sessionOrder = readStoredList(SESSION_ORDER_KEY);
|
|
111
113
|
let sessionGroups = []; // user-named groups: [{id, name, color, members:[{type,ref}]}]
|
|
112
|
-
// Sessions the user pulled out of their dispatch placement; the server's group map is not theirs to edit.
|
|
113
|
-
let sgReleased = new Set();
|
|
114
114
|
let sgDrag = null; // in-flight sidebar drag: {kind:'session'|'project'|'group', ref}
|
|
115
115
|
let searchQuery = ''; // Search query for fuzzy search
|
|
116
116
|
let allTasksCache = []; // Cache all tasks for search
|
|
@@ -146,6 +146,8 @@ let taskHighlightDimmed = false;
|
|
|
146
146
|
let focusZone = 'board'; // 'board' | 'sidebar'
|
|
147
147
|
let appConfig = { marketplaceUrl: null, costUrl: null, memoryUrl: null, scratchAvailable: false };
|
|
148
148
|
let runningTerminals = new Set();
|
|
149
|
+
// Embedded sessions stuck on Claude Code's folder-trust dialog. No hook runs before it, so the server reads it off the screen.
|
|
150
|
+
let trustWaiting = new Set();
|
|
149
151
|
let selectedSessionIdx = -1;
|
|
150
152
|
let selectedSessionKbId = null;
|
|
151
153
|
let sessionJustSelected = false;
|
|
@@ -188,6 +190,7 @@ function setSessionDismissed(id, dismissed) {
|
|
|
188
190
|
else dismissedSessionIds.delete(id);
|
|
189
191
|
persistDismissedSessions();
|
|
190
192
|
updateDismissBtnState();
|
|
193
|
+
if (dismissed) sgSweepTransient();
|
|
191
194
|
renderSessions();
|
|
192
195
|
renderActivityChip();
|
|
193
196
|
return true;
|
|
@@ -258,6 +261,7 @@ function resetState() {
|
|
|
258
261
|
sessionLimit = SESSION_PAGE;
|
|
259
262
|
filterProject = FILTER_DEFAULTS.project;
|
|
260
263
|
zenMode = false;
|
|
264
|
+
zenGroup = null;
|
|
261
265
|
store.removeItem(ZEN_KEY);
|
|
262
266
|
renderZenState();
|
|
263
267
|
searchQuery = '';
|
|
@@ -329,15 +333,26 @@ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
|
|
|
329
333
|
// info modal both look it up in `sessions` and bail when it is absent, so a session
|
|
330
334
|
// opened from outside the current filter would leave the view on the previous one.
|
|
331
335
|
// The server adds the pinned sessions itself; the ones `session open` showed are this page's.
|
|
332
|
-
|
|
333
|
-
const
|
|
336
|
+
// Group zen ignores the filters, and its session members come back through `include`.
|
|
337
|
+
const zenIds = zenGroup ? sgSubtreeIds(zenGroup) : new Set();
|
|
338
|
+
const zenMembers = sessionGroups
|
|
339
|
+
.filter((g) => zenIds.has(g.id))
|
|
340
|
+
.flatMap((g) => g.members.filter((m) => m.type === 'session').map((m) => m.ref));
|
|
341
|
+
const include = [
|
|
342
|
+
focusId,
|
|
343
|
+
revealedPlanSessionId,
|
|
344
|
+
revealedStorageSessionId,
|
|
345
|
+
...openedStickyIds,
|
|
346
|
+
...zenMembers,
|
|
347
|
+
].filter(Boolean);
|
|
348
|
+
const recent = !zenGroup && filterProject === '__recent__';
|
|
334
349
|
const res = await api('/api/sessions', {
|
|
335
350
|
query: {
|
|
336
351
|
limit: sessionLimit,
|
|
337
352
|
include: include.length ? include.join(',') : null,
|
|
338
353
|
recentHours: recent ? RECENT_PROJECT_HOURS : null,
|
|
339
|
-
project: !recent && filterProject ? filterProject : null,
|
|
340
|
-
filter: sessionFilter === 'active' ? 'active' : null,
|
|
354
|
+
project: !zenGroup && !recent && filterProject ? filterProject : null,
|
|
355
|
+
filter: !zenGroup && sessionFilter === 'active' ? 'active' : null,
|
|
341
356
|
},
|
|
342
357
|
});
|
|
343
358
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
@@ -348,6 +363,7 @@ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
|
|
|
348
363
|
lastSessionsHash = sessionsHash;
|
|
349
364
|
sessions = mergePlaceholders(newSessions);
|
|
350
365
|
markOpenSessionRead();
|
|
366
|
+
sgSweepTransient();
|
|
351
367
|
renderSessions();
|
|
352
368
|
renderActivityChip();
|
|
353
369
|
schedulePublishSession();
|
|
@@ -544,14 +560,14 @@ function toggleSection(containerId, chevronId) {
|
|
|
544
560
|
}
|
|
545
561
|
|
|
546
562
|
function isWaitingSession(s) {
|
|
547
|
-
return !!s.hasWaitingForUser;
|
|
563
|
+
return !!s.hasWaitingForUser || trustWaiting.has(s.id);
|
|
548
564
|
}
|
|
549
565
|
function sessionBarClass(s) {
|
|
550
566
|
if (isWaitingSession(s)) return 'permission-pending';
|
|
551
567
|
return s.unread ? 'unread' : '';
|
|
552
568
|
}
|
|
553
569
|
function isActiveSession(s) {
|
|
554
|
-
return !s
|
|
570
|
+
return !isWaitingSession(s) && (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents);
|
|
555
571
|
}
|
|
556
572
|
// hasRecentLog carries the server's registry-idle correction: an open-but-idle
|
|
557
573
|
// terminal keeps touching its JSONL, so mtime alone is not proof of activity.
|
|
@@ -765,8 +781,7 @@ async function fetchTasks(sessionId) {
|
|
|
765
781
|
if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
|
|
766
782
|
revealedStorageSessionId = null;
|
|
767
783
|
}
|
|
768
|
-
if (currentSessionId && currentSessionId !== sessionId
|
|
769
|
-
expandPinnedFor(currentSessionId);
|
|
784
|
+
if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
|
|
770
785
|
if (lastSessionId !== sessionId) setSwapPair(sessionId, lastSessionId);
|
|
771
786
|
const switched = sessionId !== currentSessionId;
|
|
772
787
|
currentSessionId = sessionId;
|
|
@@ -2178,7 +2193,6 @@ function applyServerPins(pins) {
|
|
|
2178
2193
|
before.size === pinnedSessionIds.size && [...before].every(([id, s]) => getSessionPinState(id) === s);
|
|
2179
2194
|
if (unchanged && !first) return;
|
|
2180
2195
|
const added = first ? [] : [...pinnedSessionIds].filter((id) => !before.has(id));
|
|
2181
|
-
for (const id of added) expandPinnedFor(id);
|
|
2182
2196
|
showPinnedSessions(added);
|
|
2183
2197
|
}
|
|
2184
2198
|
|
|
@@ -2224,8 +2238,6 @@ function unpinSession(sessionId) {
|
|
|
2224
2238
|
function toggleSessionPin(sessionId) {
|
|
2225
2239
|
if (pinnedSessionIds.has(sessionId)) return unpinSession(sessionId);
|
|
2226
2240
|
pinnedSessionIds.add(sessionId);
|
|
2227
|
-
if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
|
|
2228
|
-
expandPinnedFor(sessionId);
|
|
2229
2241
|
sendPinChange(sessionId);
|
|
2230
2242
|
renderSessions();
|
|
2231
2243
|
}
|
|
@@ -2251,7 +2263,6 @@ function handleSessionPinEvent({ id, state }) {
|
|
|
2251
2263
|
pinnedSessionIds.add(id);
|
|
2252
2264
|
stickySessionIds.add(id);
|
|
2253
2265
|
}
|
|
2254
|
-
expandPinnedFor(id);
|
|
2255
2266
|
showPinnedSessions(isAnyPinned(id) ? [id] : []);
|
|
2256
2267
|
}
|
|
2257
2268
|
|
|
@@ -3739,12 +3750,14 @@ function getFilteredSessions() {
|
|
|
3739
3750
|
if (filterProject) {
|
|
3740
3751
|
// The open session keeps its row even when it belongs to another project, so that
|
|
3741
3752
|
// revealing one by id has something to scroll to. Same exemption the active branch
|
|
3742
|
-
// above already makes, for the same reason.
|
|
3753
|
+
// above already makes, for the same reason. recentProjects lags behind the session list, so a
|
|
3754
|
+
// session in a new folder counts as recent on its own.
|
|
3755
|
+
const recentCutoff = Date.now() - RECENT_PROJECT_HOURS * 60 * 60 * 1000;
|
|
3743
3756
|
filteredSessions = filteredSessions.filter(
|
|
3744
3757
|
(s) =>
|
|
3745
3758
|
matchesProjectFilter(s.project, s.worktree?.repo) ||
|
|
3746
3759
|
s.id === currentSessionId ||
|
|
3747
|
-
(filterProject === '__recent__' && isAnyPinned(s.id)),
|
|
3760
|
+
(filterProject === '__recent__' && (isAnyPinned(s.id) || Date.parse(s.modifiedAt) >= recentCutoff)),
|
|
3748
3761
|
);
|
|
3749
3762
|
}
|
|
3750
3763
|
|
|
@@ -3847,13 +3860,22 @@ function renderSessions() {
|
|
|
3847
3860
|
if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
|
|
3848
3861
|
scheduleJustFinishedExpiry();
|
|
3849
3862
|
refreshProjectList();
|
|
3863
|
+
syncPinPeek();
|
|
3850
3864
|
|
|
3865
|
+
if (zenGroup && !sgGroupById(zenGroup)) {
|
|
3866
|
+
zenGroup = null;
|
|
3867
|
+
renderZenState();
|
|
3868
|
+
}
|
|
3851
3869
|
// Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
|
|
3852
3870
|
// and still offers everything, or there would be no way to switch sessions without leaving zen.
|
|
3871
|
+
// Group zen renders only its subtree's buckets, so it takes every session unfiltered.
|
|
3853
3872
|
const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
3854
|
-
|
|
3873
|
+
let filteredSessions;
|
|
3874
|
+
if (zenGroup) filteredSessions = applyStableSessionOrder(sessions);
|
|
3875
|
+
else if (zenMode) filteredSessions = zenSession ? [zenSession] : [];
|
|
3876
|
+
else filteredSessions = applyStableSessionOrder(getFilteredSessions());
|
|
3855
3877
|
|
|
3856
|
-
const outside = sidebarSearch.rows(zenMode ? '' : searchQuery, filteredSessions);
|
|
3878
|
+
const outside = sidebarSearch.rows(zenMode || zenGroup ? '' : searchQuery, filteredSessions);
|
|
3857
3879
|
|
|
3858
3880
|
const childDepth = new Map();
|
|
3859
3881
|
const parentOf = new Map();
|
|
@@ -3909,12 +3931,12 @@ function renderSessions() {
|
|
|
3909
3931
|
const padEmoji = store.getItem(_padEmojiKey(session.id));
|
|
3910
3932
|
const hasScratchpad = !!padEmoji || _hasScratchpad(_sessionScratchpadKey(session.id));
|
|
3911
3933
|
const tempClass =
|
|
3912
|
-
session.hasRecentLog || session.hasRecentActivity || session.inProgress || session
|
|
3934
|
+
session.hasRecentLog || session.hasRecentActivity || session.inProgress || isWaitingSession(session)
|
|
3913
3935
|
? 'warm'
|
|
3914
3936
|
: 'stale';
|
|
3915
3937
|
// Open tasks keep an idle session warm, but the green dot is for a session that is working.
|
|
3916
3938
|
const idleClass =
|
|
3917
|
-
tempClass === 'warm' && !session
|
|
3939
|
+
tempClass === 'warm' && !isWaitingSession(session) && !session.hasRecentLog && !session.hasRunningAgents
|
|
3918
3940
|
? 'idle'
|
|
3919
3941
|
: '';
|
|
3920
3942
|
const justFinishedClass = isJustFinished(session) ? 'just-finished' : '';
|
|
@@ -3946,8 +3968,8 @@ function renderSessions() {
|
|
|
3946
3968
|
${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
|
|
3947
3969
|
${session.dispatched ? `<span class="dispatched-badge"${session.dispatched.parent ? ` onclick="event.stopPropagation(); revealSession('${escAttrJs(session.dispatched.parent)}')"` : ''} title="${escapeHtml(dispatchedTitle(session.dispatched))}">${ICON_DISPATCHED}</span>` : ''}
|
|
3948
3970
|
${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>` : ''}
|
|
3949
|
-
${session
|
|
3950
|
-
${session.hasRunningAgents && !session
|
|
3971
|
+
${isWaitingSession(session) ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
|
|
3972
|
+
${session.hasRunningAgents && !isWaitingSession(session) ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
|
|
3951
3973
|
${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
|
|
3952
3974
|
</span>
|
|
3953
3975
|
</div>
|
|
@@ -3979,7 +4001,7 @@ function renderSessions() {
|
|
|
3979
4001
|
)
|
|
3980
4002
|
: '';
|
|
3981
4003
|
|
|
3982
|
-
if (filteredSessions.length === 0) {
|
|
4004
|
+
if (filteredSessions.length === 0 && !zenGroup) {
|
|
3983
4005
|
let emptyMsg = 'No sessions found';
|
|
3984
4006
|
let emptyHint = 'Tasks appear when you use Claude Code';
|
|
3985
4007
|
|
|
@@ -4036,8 +4058,8 @@ function renderSessions() {
|
|
|
4036
4058
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
4037
4059
|
const renderGroupSessions = (list, pinKey) => {
|
|
4038
4060
|
if (!groupPinned || pinnedSessionIds.size === 0) return nestChildren(list).map(renderSessionCard).join('');
|
|
4039
|
-
const
|
|
4040
|
-
if (
|
|
4061
|
+
const gPinned = nestChildren(list.filter(isInPinnedGroup));
|
|
4062
|
+
if (gPinned.length === 0) return nestChildren(list).map(renderSessionCard).join('');
|
|
4041
4063
|
const gUnpinned = nestChildren(list.filter((s) => !isInPinnedGroup(s)));
|
|
4042
4064
|
const pinCollapsed = collapsedProjectGroups.has(pinKey);
|
|
4043
4065
|
return (
|
|
@@ -4050,14 +4072,14 @@ function renderSessions() {
|
|
|
4050
4072
|
groupChevronSvg(10) +
|
|
4051
4073
|
'<span class="pinned-sub-label">Pinned</span>' +
|
|
4052
4074
|
'<span class="group-count">' +
|
|
4053
|
-
|
|
4075
|
+
gPinned.length +
|
|
4054
4076
|
'</span>' +
|
|
4055
4077
|
'<span class="pinned-ungroup-btn" title="Ungroup pinned sessions">×</span>' +
|
|
4056
4078
|
'</div>' +
|
|
4057
4079
|
'<div class="pinned-sub-items' +
|
|
4058
4080
|
(pinCollapsed ? ' collapsed' : '') +
|
|
4059
4081
|
'">' +
|
|
4060
|
-
|
|
4082
|
+
gPinned.map(renderSessionCard).join('') +
|
|
4061
4083
|
'</div>' +
|
|
4062
4084
|
'</div>' +
|
|
4063
4085
|
gUnpinned.map(renderSessionCard).join('')
|
|
@@ -4110,10 +4132,9 @@ function renderSessions() {
|
|
|
4110
4132
|
sgRest.push(session);
|
|
4111
4133
|
}
|
|
4112
4134
|
}
|
|
4113
|
-
for (const [name, g] of transientGroups) if (!sgBuckets.has(g.id)) transientGroups.delete(name);
|
|
4114
4135
|
// A group emptied by a filter hides its header; a group the user just created (no filters)
|
|
4115
4136
|
// shows its drop-here state instead of vanishing.
|
|
4116
|
-
const sgFiltering =
|
|
4137
|
+
const sgFiltering = !zenGroup && sgListFiltered();
|
|
4117
4138
|
|
|
4118
4139
|
// A group whose parent is gone renders at the top.
|
|
4119
4140
|
const sgIds = new Set(sessionGroups.map((g) => g.id));
|
|
@@ -4135,17 +4156,16 @@ function renderSessions() {
|
|
|
4135
4156
|
|
|
4136
4157
|
// flat = the "All sessions" view, which has no project sub-blocks.
|
|
4137
4158
|
const sgSectionHtml = (flat) => {
|
|
4138
|
-
|
|
4139
|
-
if (sessionGroups.length === 0 && transient.length === 0) return '';
|
|
4159
|
+
if (sessionGroups.length === 0) return '';
|
|
4140
4160
|
let out = '';
|
|
4141
|
-
for (const group of
|
|
4161
|
+
for (const group of sgChildren.get(null) || []) out += sgGroupHtml(group, flat);
|
|
4142
4162
|
if (!out) return '';
|
|
4143
4163
|
return sectionHtml(SECTION_GROUPS, 'Groups', false, out);
|
|
4144
4164
|
};
|
|
4145
4165
|
|
|
4146
4166
|
const sgGroupHtml = (group, flat) => {
|
|
4147
4167
|
const bucket = sgBuckets.get(group.id) || [];
|
|
4148
|
-
const subtree =
|
|
4168
|
+
const subtree = sgSubtreeOf(group);
|
|
4149
4169
|
if (subtree.length === 0 && sgFiltering) return '';
|
|
4150
4170
|
if (!groupPinned) bucket.sort(pinSort);
|
|
4151
4171
|
const collapsed = collapsedProjectGroups.has(sgKey(group.id));
|
|
@@ -4159,17 +4179,6 @@ function renderSessions() {
|
|
|
4159
4179
|
body = nestChildren([...bucket].sort(byMemberOrder))
|
|
4160
4180
|
.map(renderSessionCard)
|
|
4161
4181
|
.join('');
|
|
4162
|
-
} else if (group.transient) {
|
|
4163
|
-
// Each session under its own project block, so a worktree session reads as its repo's.
|
|
4164
|
-
const byProject = new Map();
|
|
4165
|
-
for (const s of bucket) {
|
|
4166
|
-
const key = sessionProjectKey(s) || '';
|
|
4167
|
-
if (!byProject.has(key)) byProject.set(key, []);
|
|
4168
|
-
byProject.get(key).push(s);
|
|
4169
|
-
}
|
|
4170
|
-
for (const [p, arr] of byProject) {
|
|
4171
|
-
body += p ? projectBlock(p, arr, true) : renderGroupSessions(arr, `__pinned_group_${group.id}__`);
|
|
4172
|
-
}
|
|
4173
4182
|
} else {
|
|
4174
4183
|
const loose = [];
|
|
4175
4184
|
const byProject = new Map();
|
|
@@ -4199,10 +4208,9 @@ function renderSessions() {
|
|
|
4199
4208
|
}
|
|
4200
4209
|
}
|
|
4201
4210
|
let childHtml = '';
|
|
4202
|
-
|
|
4211
|
+
for (const child of sgChildren.get(group.id) || []) childHtml += sgGroupHtml(child, flat);
|
|
4203
4212
|
if (!body && !childHtml) body = '<div class="sg-empty">Drop a session or a project here</div>';
|
|
4204
|
-
|
|
4205
|
-
return `${sgHeaderHtml(group, countHtml(subtree))}<div class="session-group-sessions${collapsed ? ' collapsed' : ''}"${idAttr}>${body}${childHtml}</div>`;
|
|
4213
|
+
return `${sgHeaderHtml(group, countHtml(subtree))}<div class="session-group-sessions${collapsed ? ' collapsed' : ''}" data-group-id="${escapeHtml(group.id)}">${body}${childHtml}</div>`;
|
|
4206
4214
|
};
|
|
4207
4215
|
|
|
4208
4216
|
let html = '';
|
|
@@ -4213,8 +4221,10 @@ function renderSessions() {
|
|
|
4213
4221
|
</div>`;
|
|
4214
4222
|
}
|
|
4215
4223
|
|
|
4216
|
-
|
|
4217
|
-
|
|
4224
|
+
if (zenGroup) {
|
|
4225
|
+
sessionsList.innerHTML = sgGroupHtml(sgGroupById(zenGroup), sessionFilter !== 'active');
|
|
4226
|
+
} else if (sessionFilter === 'active') {
|
|
4227
|
+
// Group active sessions by project
|
|
4218
4228
|
// Auto-expand a collapsed section when newly-active work lands in it (live refresh).
|
|
4219
4229
|
expandActiveGroups({ onlyNew: true });
|
|
4220
4230
|
html += sgSectionHtml(false);
|
|
@@ -4253,7 +4263,7 @@ function renderSessions() {
|
|
|
4253
4263
|
// The section header is rendered even when nothing is left under it: it is the drop target
|
|
4254
4264
|
// for ungrouping. Its body is skipped while collapsed — nothing would be visible, and this is
|
|
4255
4265
|
// the whole project list, re-parsed on every live refresh.
|
|
4256
|
-
const sectioned = sessionGroups.length > 0
|
|
4266
|
+
const sectioned = sessionGroups.length > 0;
|
|
4257
4267
|
let projectsHtml = '';
|
|
4258
4268
|
if (!sectioned || !collapsedProjectGroups.has(SECTION_PROJECTS)) {
|
|
4259
4269
|
for (const [projectPath, projectSessions] of sortedGroups) {
|
|
@@ -4282,7 +4292,7 @@ function renderSessions() {
|
|
|
4282
4292
|
|
|
4283
4293
|
sessionsList.innerHTML = html + outsideHtml;
|
|
4284
4294
|
} else {
|
|
4285
|
-
const sectioned = sessionGroups.length > 0
|
|
4295
|
+
const sectioned = sessionGroups.length > 0;
|
|
4286
4296
|
const restHtml =
|
|
4287
4297
|
sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
|
|
4288
4298
|
const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
|
|
@@ -4405,6 +4415,30 @@ function renderOwnerBadge(task) {
|
|
|
4405
4415
|
return `<button type="button" class="task-owner-badge task-owner-link" style="${style}" title="Open dispatched session" onclick="event.stopPropagation(); revealSession('${escAttrJs(target)}')">${ICON_DISPATCHED}${escapeHtml(task.owner)}</button>`;
|
|
4406
4416
|
}
|
|
4407
4417
|
|
|
4418
|
+
// Card chips that a tool writing the task file adds: metadata.chips = [{label, title?, tone?}], tone one of
|
|
4419
|
+
// muted, warn, accent. The writer is outside cck, so the list is capped and every field is checked.
|
|
4420
|
+
function taskChips(task) {
|
|
4421
|
+
const chips = task.metadata?.chips;
|
|
4422
|
+
if (!Array.isArray(chips)) return [];
|
|
4423
|
+
return chips
|
|
4424
|
+
.filter((c) => typeof c?.label === 'string' && c.label.trim())
|
|
4425
|
+
.slice(0, 4)
|
|
4426
|
+
.map((c) => ({
|
|
4427
|
+
label: c.label.trim().slice(0, 24),
|
|
4428
|
+
title: typeof c.title === 'string' ? c.title.slice(0, 200) : '',
|
|
4429
|
+
tone: ['muted', 'warn', 'accent'].includes(c.tone) ? c.tone : 'muted',
|
|
4430
|
+
}));
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4433
|
+
function renderTaskChips(chips) {
|
|
4434
|
+
return chips
|
|
4435
|
+
.map(
|
|
4436
|
+
(c) =>
|
|
4437
|
+
`<span class="task-chip task-chip-${escapeHtml(c.tone)}"${c.title ? ` title="${escapeHtml(c.title)}"` : ''}>${escapeHtml(c.label)}</span>`,
|
|
4438
|
+
)
|
|
4439
|
+
.join('');
|
|
4440
|
+
}
|
|
4441
|
+
|
|
4408
4442
|
function renderTaskCard(task) {
|
|
4409
4443
|
const blockers = openBlockers(task);
|
|
4410
4444
|
const isBlocked = blockers.length > 0;
|
|
@@ -4412,6 +4446,9 @@ function renderTaskCard(task) {
|
|
|
4412
4446
|
const taskId = useSlug ? `${(task._taskDir || task.sessionId || '')?.slice(0, 4)}-${task.id}` : task.id;
|
|
4413
4447
|
const statusClass = task.status.replace('_', '-');
|
|
4414
4448
|
const actualSessionId = task._taskDir || task.sessionId || currentSessionId || '';
|
|
4449
|
+
const isDim = task.metadata?.dim === true;
|
|
4450
|
+
const chips = taskChips(task);
|
|
4451
|
+
const state = [task.status.replace('_', ' '), ...chips.map((c) => c.label)].join(', ');
|
|
4415
4452
|
|
|
4416
4453
|
return `
|
|
4417
4454
|
<div
|
|
@@ -4423,10 +4460,11 @@ function renderTaskCard(task) {
|
|
|
4423
4460
|
draggable="true"
|
|
4424
4461
|
ondragstart="onCardDragStart(event)"
|
|
4425
4462
|
ondragend="onCardDragEnd(event)"
|
|
4426
|
-
class="task-card ${statusClass} ${isBlocked ? 'blocked' : ''}"
|
|
4427
|
-
aria-label="${escapeHtml(task.subject)} — ${
|
|
4463
|
+
class="task-card ${statusClass} ${isBlocked ? 'blocked' : ''} ${isDim ? 'dim' : ''}"
|
|
4464
|
+
aria-label="${escapeHtml(task.subject)} — ${escapeHtml(state)}">
|
|
4428
4465
|
<div class="task-id">
|
|
4429
4466
|
<span>#${taskId}</span>
|
|
4467
|
+
${renderTaskChips(chips)}
|
|
4430
4468
|
${isBlocked ? `<span class="task-blocked" title="Blocked">waiting on ${blockers.map((id) => `#${escapeHtml(id)}`).join(' ')}</span>` : ''}
|
|
4431
4469
|
${task.owner ? renderOwnerBadge(task) : ''}
|
|
4432
4470
|
</div>
|
|
@@ -4712,7 +4750,8 @@ async function onColumnDrop(e) {
|
|
|
4712
4750
|
// User-named groups that hold whole projects and individual sessions, dragged in from the
|
|
4713
4751
|
// sidebar. They render above the project groups. Membership is a workspace preference kept on
|
|
4714
4752
|
// the server; an id missing from `sessions` is NOT proof the session is gone (sessionLimit
|
|
4715
|
-
// windows the list), so membership is never garbage-collected on load.
|
|
4753
|
+
// windows the list), so membership is never garbage-collected on load. Transient groups go only
|
|
4754
|
+
// on a full Active list (sgSweepTransient).
|
|
4716
4755
|
const SESSION_GROUPS_KEY = 'sessionGroups';
|
|
4717
4756
|
const SG_ACTION_SELECTOR =
|
|
4718
4757
|
'.session-pin-btn, .team-info-btn, .plan-indicator, .scratchpad-badge, .bookmarks-badge, .linked-docs-badge, .project-view-btn, .project-new-btn, .header-pad-btn, .group-path-toggle, .pinned-ungroup-btn, .sg-action';
|
|
@@ -4747,10 +4786,10 @@ let sgSeenRev = 0;
|
|
|
4747
4786
|
async function syncSessionGroups() {
|
|
4748
4787
|
const migrated = !!store.getItem(GROUPS_MIGRATED_AT);
|
|
4749
4788
|
const local = migrated ? null : store.readJson(SESSION_GROUPS_KEY);
|
|
4750
|
-
const hasLocal = local?.groups?.length > 0
|
|
4789
|
+
const hasLocal = local?.groups?.length > 0;
|
|
4751
4790
|
try {
|
|
4752
4791
|
const res = hasLocal
|
|
4753
|
-
? await api('/api/groups/import', { method: 'POST', body: { groups: local.groups
|
|
4792
|
+
? await api('/api/groups/import', { method: 'POST', body: { groups: local.groups } })
|
|
4754
4793
|
: await api('/api/groups');
|
|
4755
4794
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
4756
4795
|
sgTakeServer(await res.json());
|
|
@@ -4791,6 +4830,7 @@ function sgApplyState(saved) {
|
|
|
4791
4830
|
id: g.id,
|
|
4792
4831
|
name: typeof g.name === 'string' && g.name.trim() ? g.name : 'Group',
|
|
4793
4832
|
parent: typeof g.parent === 'string' ? g.parent : null,
|
|
4833
|
+
...(g.transient === true ? { transient: true } : {}),
|
|
4794
4834
|
members: (Array.isArray(g.members) ? g.members : [])
|
|
4795
4835
|
.filter((m) => m && (m.type === 'project' || m.type === 'session') && typeof m.ref === 'string')
|
|
4796
4836
|
.map((m) => {
|
|
@@ -4801,8 +4841,6 @@ function sgApplyState(saved) {
|
|
|
4801
4841
|
return out;
|
|
4802
4842
|
}),
|
|
4803
4843
|
}));
|
|
4804
|
-
const released = saved?.released;
|
|
4805
|
-
sgReleased = new Set(Array.isArray(released) ? released.filter((id) => typeof id === 'string') : []);
|
|
4806
4844
|
}
|
|
4807
4845
|
|
|
4808
4846
|
function persistSessionGroups() {
|
|
@@ -4826,7 +4864,7 @@ async function sgFlush() {
|
|
|
4826
4864
|
try {
|
|
4827
4865
|
res = await api('/api/groups', {
|
|
4828
4866
|
method: 'PUT',
|
|
4829
|
-
body: { rev: sgRev, groups: sessionGroups
|
|
4867
|
+
body: { rev: sgRev, groups: sessionGroups },
|
|
4830
4868
|
});
|
|
4831
4869
|
} catch (e) {
|
|
4832
4870
|
console.warn('[groups] save failed:', e.message);
|
|
@@ -4856,8 +4894,7 @@ async function sgFlush() {
|
|
|
4856
4894
|
// The server drops what breaks its rules (a member in two groups, a group past the limit) and
|
|
4857
4895
|
// trims long names; the board shows what was saved.
|
|
4858
4896
|
function sgTakeCleaned(data) {
|
|
4859
|
-
|
|
4860
|
-
if (saved === JSON.stringify({ groups: sessionGroups, released: [...sgReleased] })) return;
|
|
4897
|
+
if (JSON.stringify(data.groups) === JSON.stringify(sessionGroups)) return;
|
|
4861
4898
|
sgApplyState(data);
|
|
4862
4899
|
renderSessions();
|
|
4863
4900
|
}
|
|
@@ -4869,6 +4906,12 @@ function sgGroupById(id) {
|
|
|
4869
4906
|
// Matches MAX_DEPTH in lib/user-groups.js.
|
|
4870
4907
|
const SG_MAX_DEPTH = 8;
|
|
4871
4908
|
|
|
4909
|
+
function sgSubtreeIds(rootId) {
|
|
4910
|
+
const ids = new Set([rootId]);
|
|
4911
|
+
for (const id of ids) for (const g of sessionGroups) if (g.parent === id) ids.add(g.id);
|
|
4912
|
+
return ids;
|
|
4913
|
+
}
|
|
4914
|
+
|
|
4872
4915
|
function sgPathOf(group) {
|
|
4873
4916
|
const names = [];
|
|
4874
4917
|
for (let g = group; g && names.length < SG_MAX_DEPTH; g = sgGroupById(g.parent)) names.unshift(g.name);
|
|
@@ -4887,26 +4930,30 @@ function sgSessionParent(id) {
|
|
|
4887
4930
|
|
|
4888
4931
|
// An individually placed session wins over the placement of its project.
|
|
4889
4932
|
function sgGroupForSession(session) {
|
|
4890
|
-
return sgUserGroupFor(session) || sgDispatchGroupFor(session);
|
|
4891
|
-
}
|
|
4892
|
-
|
|
4893
|
-
function sgUserGroupFor(session) {
|
|
4894
4933
|
const project = sessionProjectKey(session);
|
|
4895
4934
|
return sgGroupOf('session', session.id) || (project ? sgGroupOf('project', project) : null);
|
|
4896
4935
|
}
|
|
4897
4936
|
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4937
|
+
function sgListFiltered() {
|
|
4938
|
+
return !!searchQuery || activityFilter.size > 0 || (!!filterProject && filterProject !== '__recent__');
|
|
4939
|
+
}
|
|
4940
|
+
|
|
4941
|
+
// A transient group (`dispatch start --group` made it) goes once the Active view shows none of its
|
|
4942
|
+
// sessions: they ended or were dismissed. A session still starting has no card yet, so its
|
|
4943
|
+
// terminal or placeholder counts. Only the full Active list can tell, so other views keep them.
|
|
4944
|
+
function sgSweepTransient() {
|
|
4945
|
+
if (sgRev === null || !sessionGroups.some((g) => g.transient)) return;
|
|
4946
|
+
if (zenGroup || zenMode || sessionFilter !== 'active' || sessionsHasMore || sgListFiltered()) return;
|
|
4947
|
+
const shown = new Set(getFilteredSessions().map((s) => s.id));
|
|
4948
|
+
const starting = (id) => !dismissedSessionIds.has(id) && (runningTerminals.has(id) || dispatchSpecs.has(id));
|
|
4949
|
+
const present = (m) => m.type !== 'session' || shown.has(m.ref) || starting(m.ref);
|
|
4950
|
+
const gone = groupTree.cascade(sessionGroups, (g) => !!g.transient && !g.members.some(present));
|
|
4951
|
+
if (!gone.size) return;
|
|
4952
|
+
let collapsed = false;
|
|
4953
|
+
for (const g of gone) collapsed = collapsedProjectGroups.delete(sgKey(g.id)) || collapsed;
|
|
4954
|
+
if (collapsed) persistCollapsedGroups();
|
|
4955
|
+
sessionGroups = sessionGroups.filter((g) => !gone.has(g));
|
|
4956
|
+
persistSessionGroups();
|
|
4910
4957
|
}
|
|
4911
4958
|
|
|
4912
4959
|
const childDepthClass = (depth) => (depth ? ` sg-child sg-depth-${depth}` : '');
|
|
@@ -4917,26 +4964,9 @@ function dispatchedTitle({ parent }) {
|
|
|
4917
4964
|
return `Started by ${by}${parent ? ' — click to reveal it' : ''}`;
|
|
4918
4965
|
}
|
|
4919
4966
|
|
|
4920
|
-
// A
|
|
4921
|
-
function
|
|
4922
|
-
|
|
4923
|
-
const name = session.dispatchGroup;
|
|
4924
|
-
if (!name) return null;
|
|
4925
|
-
return sessionGroups.find((g) => g.name.toLowerCase() === name) || sgTransientGroup(name);
|
|
4926
|
-
}
|
|
4927
|
-
|
|
4928
|
-
function sgKeepTransient(name) {
|
|
4929
|
-
const group = sgTransientGroup(name);
|
|
4930
|
-
const members = sessions.filter((s) => sgGroupForSession(s) === group);
|
|
4931
|
-
const kept = sgCreateGroup(name);
|
|
4932
|
-
for (const s of members) kept.members.push({ type: 'session', ref: s.id });
|
|
4933
|
-
persistSessionGroups();
|
|
4934
|
-
}
|
|
4935
|
-
|
|
4936
|
-
function sgDeleteTransient(name) {
|
|
4937
|
-
const group = sgTransientGroup(name);
|
|
4938
|
-
for (const s of sessions) if (sgGroupForSession(s) === group) sgReleased.add(s.id);
|
|
4939
|
-
persistSessionGroups();
|
|
4967
|
+
// A group the user edits is theirs: Keep, a rename or a placement clears the flag.
|
|
4968
|
+
function sgKeep(group) {
|
|
4969
|
+
delete group.transient;
|
|
4940
4970
|
}
|
|
4941
4971
|
|
|
4942
4972
|
function sgDetach(type, ref) {
|
|
@@ -4962,6 +4992,7 @@ function sgHostOf(group, session) {
|
|
|
4962
4992
|
function sgAssign(groupId, type, ref, opts = {}) {
|
|
4963
4993
|
const group = sgGroupById(groupId);
|
|
4964
4994
|
if (!group) return;
|
|
4995
|
+
sgKeep(group);
|
|
4965
4996
|
const parent = opts.parent !== undefined ? opts.parent : sgMemberOf(group, type, ref)?.parent;
|
|
4966
4997
|
sgDetach(type, ref);
|
|
4967
4998
|
// Pulling a whole project in supersedes the individual placements of its sessions.
|
|
@@ -5006,6 +5037,20 @@ function sgDeleteGroup(id) {
|
|
|
5006
5037
|
persistSessionGroups();
|
|
5007
5038
|
}
|
|
5008
5039
|
|
|
5040
|
+
async function sgConfirmDelete(group) {
|
|
5041
|
+
const ok = await confirmModal({
|
|
5042
|
+
title: 'Delete this group?',
|
|
5043
|
+
message: 'Its sessions and projects go back to Projects.',
|
|
5044
|
+
chip: group.name,
|
|
5045
|
+
okLabel: 'Delete group',
|
|
5046
|
+
});
|
|
5047
|
+
if (ok) {
|
|
5048
|
+
sgDeleteGroup(group.id);
|
|
5049
|
+
renderSessions();
|
|
5050
|
+
}
|
|
5051
|
+
return ok;
|
|
5052
|
+
}
|
|
5053
|
+
|
|
5009
5054
|
// Move one member of a group in front of another — the user's own order inside the group.
|
|
5010
5055
|
function sgReorderMember(groupId, drag, target) {
|
|
5011
5056
|
const group = sgGroupById(groupId);
|
|
@@ -5059,13 +5104,10 @@ function sgHeaderHtml(group, countHtml) {
|
|
|
5059
5104
|
const nameHtml = editing
|
|
5060
5105
|
? `<input class="sg-name-input" type="text" value="${escapeHtml(group.name)}" aria-label="Group name" spellcheck="false">`
|
|
5061
5106
|
: `<span class="group-name">${escapeHtml(group.name)}</span>`;
|
|
5062
|
-
const
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
? `<span class="sg-action sg-keep" title="Keep this group after its sessions end">Keep</span>
|
|
5067
|
-
<span class="sg-action sg-delete" title="Delete group (sessions return to Projects)">×</span>`
|
|
5068
|
-
: `<span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>
|
|
5107
|
+
const hint = group.transient ? 'goes when its sessions leave the Active view' : 'drop sessions or projects here';
|
|
5108
|
+
const attrs = `class="session-group-header${group.transient ? ' sg-transient' : ''}${collapsed ? ' collapsed' : ''}${group.id === zenGroup ? ' sg-zen' : ''}" draggable="${editing ? 'false' : 'true'}" data-group-id="${escapeHtml(group.id)}" title="${escapeHtml(group.name)} — ${hint}"`;
|
|
5109
|
+
const actions = `${group.transient ? '<span class="sg-action sg-keep" title="Keep this group after its sessions end">Keep</span>' : ''}
|
|
5110
|
+
<span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>
|
|
5069
5111
|
<span class="sg-action sg-delete" title="Delete group (members return to Projects)">×</span>
|
|
5070
5112
|
${headerPadBtnHtml(_groupScratchpadKey(group.id), group.name)}`;
|
|
5071
5113
|
return `
|
|
@@ -5100,6 +5142,7 @@ function sgCommitRename(input, save) {
|
|
|
5100
5142
|
sgEditingId = null;
|
|
5101
5143
|
if (save && group && value && value !== group.name) {
|
|
5102
5144
|
group.name = value;
|
|
5145
|
+
sgKeep(group);
|
|
5103
5146
|
persistSessionGroups();
|
|
5104
5147
|
}
|
|
5105
5148
|
renderSessions();
|
|
@@ -5234,8 +5277,7 @@ function sgGroupDropZone(target) {
|
|
|
5234
5277
|
: null;
|
|
5235
5278
|
}
|
|
5236
5279
|
const dragSession = sgDrag.kind === 'session' ? sessions.find((x) => x.id === sgDrag.ref) : null;
|
|
5237
|
-
|
|
5238
|
-
if (zone.classList.contains('sg-ungroup-zone')) return dragGroup || releasable ? { zone } : null;
|
|
5280
|
+
if (zone.classList.contains('sg-ungroup-zone')) return dragGroup ? { zone } : null;
|
|
5239
5281
|
const fromGroup = dragSession ? sgGroupForSession(dragSession) : dragGroup;
|
|
5240
5282
|
const fromGroupId = fromGroup?.id || null;
|
|
5241
5283
|
// A drop that lands on the host the session already has changes nothing, and highlighting it
|
|
@@ -5255,8 +5297,7 @@ function sgGroupDropZone(target) {
|
|
|
5255
5297
|
const targetSession = sessions.find((s) => s.id === targetSessionId);
|
|
5256
5298
|
const targetGroup = targetSession ? sgGroupForSession(targetSession) : null;
|
|
5257
5299
|
const targetGroupId = targetGroup?.id || null;
|
|
5258
|
-
|
|
5259
|
-
if (targetGroup && !targetGroup.transient) {
|
|
5300
|
+
if (targetGroup) {
|
|
5260
5301
|
// Stacking onto a card nests the dragged session under it, in that card's placement, project
|
|
5261
5302
|
// block included.
|
|
5262
5303
|
if (sgSessionParent(sgDrag.ref) === targetSessionId && fromGroupId === targetGroupId) return null;
|
|
@@ -5363,7 +5404,6 @@ function sgOnDrop(e) {
|
|
|
5363
5404
|
}
|
|
5364
5405
|
} else {
|
|
5365
5406
|
sgDetach(drag.kind, drag.ref);
|
|
5366
|
-
if (drag.kind === 'session') sgReleased.add(drag.ref);
|
|
5367
5407
|
persistSessionGroups();
|
|
5368
5408
|
}
|
|
5369
5409
|
sgFinishDrag();
|
|
@@ -5776,6 +5816,10 @@ function setGroupCollapsed(header, collapsed) {
|
|
|
5776
5816
|
if (!header) return;
|
|
5777
5817
|
const projectPath = header.dataset.groupPath;
|
|
5778
5818
|
if (collapsed === collapsedProjectGroups.has(projectPath)) return;
|
|
5819
|
+
if (collapsed && projectPath === pinPeekKey) {
|
|
5820
|
+
pinPeekKey = null;
|
|
5821
|
+
pinPeekClosedFor = currentSessionId;
|
|
5822
|
+
}
|
|
5779
5823
|
if (collapsed) collapsedProjectGroups.add(projectPath);
|
|
5780
5824
|
else collapsedProjectGroups.delete(projectPath);
|
|
5781
5825
|
header.classList.toggle('collapsed', collapsed);
|
|
@@ -5785,7 +5829,7 @@ function setGroupCollapsed(header, collapsed) {
|
|
|
5785
5829
|
}
|
|
5786
5830
|
|
|
5787
5831
|
function persistCollapsedGroups() {
|
|
5788
|
-
store.writeJson(COLLAPSED_GROUPS_KEY, [...collapsedProjectGroups]);
|
|
5832
|
+
store.writeJson(COLLAPSED_GROUPS_KEY, [...collapsedProjectGroups, ...(pinPeekKey ? [pinPeekKey] : [])]);
|
|
5789
5833
|
}
|
|
5790
5834
|
|
|
5791
5835
|
let prevActiveSessionIds = null; // null until primed by the first onlyNew pass
|
|
@@ -5815,16 +5859,16 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
5815
5859
|
}
|
|
5816
5860
|
|
|
5817
5861
|
// Drops every collapse key that could hide this session: its project, a named group wrapping
|
|
5818
|
-
// the project or the session itself and the project block it renders under there, the
|
|
5819
|
-
//
|
|
5862
|
+
// the project or the session itself and the project block it renders under there, and the section header that holds it
|
|
5863
|
+
// (a collapsed Pinned sub-section opens only for the open session, see syncPinPeek):
|
|
5820
5864
|
// Groups for a grouped session, else Projects or Sessions, which depends on the view. Returns
|
|
5821
5865
|
// whether anything changed.
|
|
5822
5866
|
function uncollapseFor(session) {
|
|
5823
5867
|
const group = sgGroupForSession(session);
|
|
5824
5868
|
const keys = [
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
5869
|
+
...(group
|
|
5870
|
+
? [sgKey(group.id), sgHostOf(group, session), SECTION_GROUPS]
|
|
5871
|
+
: [sessionProjectKey(session) || '__ungrouped__', SECTION_PROJECTS, SECTION_SESSIONS]),
|
|
5828
5872
|
];
|
|
5829
5873
|
let changed = false;
|
|
5830
5874
|
for (const key of keys) {
|
|
@@ -5836,23 +5880,25 @@ function uncollapseFor(session) {
|
|
|
5836
5880
|
function pinnedCollapseKeys(session) {
|
|
5837
5881
|
if (!isInPinnedGroup(session)) return [];
|
|
5838
5882
|
const group = sgGroupForSession(session);
|
|
5883
|
+
if (!group) return [pinKey(sessionProjectKey(session) || '__ungrouped__')];
|
|
5839
5884
|
const host = sgHostOf(group, session);
|
|
5840
|
-
return [
|
|
5841
|
-
pinKey(sessionProjectKey(session) || '__ungrouped__'),
|
|
5842
|
-
host && pinKey(host),
|
|
5843
|
-
group && `__pinned_group_${group.id}__`,
|
|
5844
|
-
];
|
|
5885
|
+
return [host ? pinKey(host) : `__pinned_group_${group.id}__`];
|
|
5845
5886
|
}
|
|
5846
5887
|
|
|
5847
|
-
// A
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
5854
|
-
|
|
5855
|
-
|
|
5888
|
+
// A Pinned sub-section the user collapsed opens while the open session is in it and closes once
|
|
5889
|
+
// the user leaves that session. The peek is never saved: persistCollapsedGroups writes the key back.
|
|
5890
|
+
let pinPeekKey = null;
|
|
5891
|
+
let pinPeekClosedFor = null;
|
|
5892
|
+
|
|
5893
|
+
function syncPinPeek() {
|
|
5894
|
+
if (pinPeekClosedFor !== currentSessionId) pinPeekClosedFor = null;
|
|
5895
|
+
const session = currentSessionId && !pinPeekClosedFor ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
5896
|
+
const key = session ? (pinnedCollapseKeys(session)[0] ?? null) : null;
|
|
5897
|
+
const want = key && (key === pinPeekKey || collapsedProjectGroups.has(key)) ? key : null;
|
|
5898
|
+
if (want === pinPeekKey) return;
|
|
5899
|
+
if (pinPeekKey) collapsedProjectGroups.add(pinPeekKey);
|
|
5900
|
+
if (want) collapsedProjectGroups.delete(want);
|
|
5901
|
+
pinPeekKey = want;
|
|
5856
5902
|
}
|
|
5857
5903
|
|
|
5858
5904
|
function isGroupHeader(el) {
|
|
@@ -6181,6 +6227,23 @@ async function saveTaskField(taskId, sessionId, field, value) {
|
|
|
6181
6227
|
}
|
|
6182
6228
|
}
|
|
6183
6229
|
|
|
6230
|
+
const DETAIL_LAYOUT_KEY = 'cck-detail-layout';
|
|
6231
|
+
|
|
6232
|
+
function setDetailExpanded(expanded) {
|
|
6233
|
+
detailPanel.classList.toggle('expanded', expanded);
|
|
6234
|
+
const btn = document.getElementById('expand-detail');
|
|
6235
|
+
btn.innerHTML = showIcon(expanded ? 'restore' : 'expand');
|
|
6236
|
+
btn.title = expanded ? 'Restore (E)' : 'Expand (E)';
|
|
6237
|
+
btn.setAttribute('aria-label', expanded ? 'Restore' : 'Expand');
|
|
6238
|
+
store.setItem(DETAIL_LAYOUT_KEY, expanded ? 'expanded' : '');
|
|
6239
|
+
}
|
|
6240
|
+
|
|
6241
|
+
function initDetailLayout() {
|
|
6242
|
+
document.getElementById('expand-detail').onclick = () =>
|
|
6243
|
+
setDetailExpanded(!detailPanel.classList.contains('expanded'));
|
|
6244
|
+
setDetailExpanded(store.getItem(DETAIL_LAYOUT_KEY) === 'expanded');
|
|
6245
|
+
}
|
|
6246
|
+
|
|
6184
6247
|
function closeDetailPanel() {
|
|
6185
6248
|
detailPanel.classList.remove('visible');
|
|
6186
6249
|
document.getElementById('delete-task-btn').style.display = 'none';
|
|
@@ -6255,6 +6318,9 @@ async function deleteTask(taskId, sessionId) {
|
|
|
6255
6318
|
okLabel: 'Delete task',
|
|
6256
6319
|
});
|
|
6257
6320
|
if (!ok) return;
|
|
6321
|
+
const info = taskId === selectedTaskId ? getSelectedCardInfo() : null;
|
|
6322
|
+
const colCards = info ? Array.from(COLUMNS[info.colIndex].el.querySelectorAll('.task-card')) : [];
|
|
6323
|
+
const next = colCards[info?.cardIndex + 1] || colCards[info?.cardIndex - 1];
|
|
6258
6324
|
|
|
6259
6325
|
try {
|
|
6260
6326
|
const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'DELETE' });
|
|
@@ -6262,6 +6328,14 @@ async function deleteTask(taskId, sessionId) {
|
|
|
6262
6328
|
if (res.ok) {
|
|
6263
6329
|
closeDetailPanel();
|
|
6264
6330
|
await refreshCurrentView();
|
|
6331
|
+
if (info && focusZone === 'board') {
|
|
6332
|
+
if (next) {
|
|
6333
|
+
selectTask(next.dataset.taskId, next.dataset.sessionId);
|
|
6334
|
+
} else {
|
|
6335
|
+
selectedTaskId = null;
|
|
6336
|
+
navigateVertical(1);
|
|
6337
|
+
}
|
|
6338
|
+
}
|
|
6265
6339
|
} else {
|
|
6266
6340
|
const error = await res.json().catch(() => ({}));
|
|
6267
6341
|
showToast(`Failed to delete task: ${error.error || 'Unknown error'}`, 'error');
|
|
@@ -6298,6 +6372,7 @@ const SHORTCUT_TABS = [
|
|
|
6298
6372
|
rows: [
|
|
6299
6373
|
{ keys: ['Enter'], label: 'Toggle task detail panel' },
|
|
6300
6374
|
{ keys: ['D'], label: 'Delete selected task' },
|
|
6375
|
+
{ keys: ['E'], label: 'Expand / restore task detail panel' },
|
|
6301
6376
|
{ keys: ['N'], label: 'Toggle scratchpad (in the sidebar: the item under the cursor)' },
|
|
6302
6377
|
{ keys: ['Ctrl', 'E'], combo: true, label: 'Set the session scratchpad emoji (in the scratchpad)' },
|
|
6303
6378
|
{ keys: ['R'], label: 'Refresh data' },
|
|
@@ -6321,7 +6396,12 @@ const SHORTCUT_TABS = [
|
|
|
6321
6396
|
{ keys: ['\\'], label: 'Toggle sidebar' },
|
|
6322
6397
|
{ keys: ['T'], label: 'Toggle theme' },
|
|
6323
6398
|
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
6324
|
-
{ keys: ['
|
|
6399
|
+
{ keys: ['←', '→'], label: 'Storage manager: switch tab' },
|
|
6400
|
+
{
|
|
6401
|
+
keys: ['Ctrl', 'Shift', 'Z'],
|
|
6402
|
+
combo: true,
|
|
6403
|
+
label: 'Zen mode (current session, or the group under the cursor)',
|
|
6404
|
+
},
|
|
6325
6405
|
{ keys: ['Ctrl', 'Shift', 'K'], combo: true, label: 'Show / hide Kanbot' },
|
|
6326
6406
|
{ keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
|
|
6327
6407
|
{ keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
|
|
@@ -6937,12 +7017,17 @@ function _renderOrphanGroup(count, innerHtml) {
|
|
|
6937
7017
|
return _renderProjectGroup('Orphaned', `<span class="storage-item-badge orphan">${count}</span>`, innerHtml);
|
|
6938
7018
|
}
|
|
6939
7019
|
|
|
7020
|
+
function _markStorageTab(tab) {
|
|
7021
|
+
document.querySelectorAll('.storage-tab').forEach((t) => {
|
|
7022
|
+
t.classList.toggle('active', t.dataset.tab === tab);
|
|
7023
|
+
t.setAttribute('aria-selected', String(t.dataset.tab === tab));
|
|
7024
|
+
});
|
|
7025
|
+
}
|
|
7026
|
+
|
|
6940
7027
|
async function showStorageManager() {
|
|
6941
7028
|
_storageKnownSessions = null;
|
|
6942
7029
|
_updateStorageTotal();
|
|
6943
|
-
|
|
6944
|
-
t.classList.toggle('active', t.dataset.tab === 'sessions');
|
|
6945
|
-
});
|
|
7030
|
+
_markStorageTab('sessions');
|
|
6946
7031
|
const modal = document.getElementById('storage-modal');
|
|
6947
7032
|
modal.classList.add('visible');
|
|
6948
7033
|
const known = await _fetchKnownSessions().catch(() => null);
|
|
@@ -6957,9 +7042,14 @@ function closeStorageManager() {
|
|
|
6957
7042
|
|
|
6958
7043
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
6959
7044
|
function switchStorageTab(tab) {
|
|
6960
|
-
|
|
6961
|
-
|
|
6962
|
-
|
|
7045
|
+
_markStorageTab(tab);
|
|
7046
|
+
_renderStorageTab();
|
|
7047
|
+
}
|
|
7048
|
+
|
|
7049
|
+
function stepStorageTab(step) {
|
|
7050
|
+
const tabs = [...document.querySelectorAll('.storage-tab')];
|
|
7051
|
+
const i = tabs.findIndex((t) => t.classList.contains('active'));
|
|
7052
|
+
_markStorageTab(tabs[(i + step + tabs.length) % tabs.length].dataset.tab);
|
|
6963
7053
|
_renderStorageTab();
|
|
6964
7054
|
}
|
|
6965
7055
|
|
|
@@ -6968,9 +7058,14 @@ function _renderStorageTab() {
|
|
|
6968
7058
|
if (!modal?.classList.contains('visible')) return;
|
|
6969
7059
|
const body = document.getElementById('storage-modal-body');
|
|
6970
7060
|
const tab = document.querySelector('.storage-tab.active')?.dataset.tab || 'sessions';
|
|
7061
|
+
const groupsClean = document.getElementById('storage-groups-clean-btn');
|
|
7062
|
+
groupsClean.style.display = tab === 'groups' ? '' : 'none';
|
|
7063
|
+
groupsClean.disabled = true;
|
|
6971
7064
|
if (tab === 'sessions') body.innerHTML = _renderStorageSessions();
|
|
7065
|
+
else if (tab === 'dismissed') body.innerHTML = _renderStorageDismissed();
|
|
6972
7066
|
else if (tab === 'scratchpads') body.innerHTML = _renderStorageScratchpads();
|
|
6973
7067
|
else if (tab === 'linked-docs') body.innerHTML = _renderStorageLinkedDocs();
|
|
7068
|
+
else if (tab === 'groups') _renderStorageGroups(body);
|
|
6974
7069
|
}
|
|
6975
7070
|
|
|
6976
7071
|
function _renderStorageSessions() {
|
|
@@ -7046,6 +7141,35 @@ function _renderStorageSessions() {
|
|
|
7046
7141
|
return html;
|
|
7047
7142
|
}
|
|
7048
7143
|
|
|
7144
|
+
function _renderStorageDismissed() {
|
|
7145
|
+
const ids = [...dismissedSessionIds.keys()];
|
|
7146
|
+
if (!ids.length) return '<div class="storage-empty">No dismissed sessions</div>';
|
|
7147
|
+
const { groups, orphans } = _groupByProject(ids);
|
|
7148
|
+
const renderItem = ({ id, session }) => `<div class="storage-item">
|
|
7149
|
+
<span class="storage-item-id">${_sessionLabel(session, id)}</span>
|
|
7150
|
+
<span class="storage-item-meta">dismissed ${formatDate(dismissedSessionIds.get(id))}</span>
|
|
7151
|
+
<div class="storage-item-actions">
|
|
7152
|
+
<button onclick="_storageViewSession('${escAttrJs(id)}')">View</button>
|
|
7153
|
+
<button onclick="_storageRestoreSession('${escAttrJs(id)}')">Restore</button>
|
|
7154
|
+
</div>
|
|
7155
|
+
</div>`;
|
|
7156
|
+
let html = '';
|
|
7157
|
+
for (const [project, items] of groups) {
|
|
7158
|
+
html += _renderProjectGroup(
|
|
7159
|
+
escapeHtml(_projectLabel(project)),
|
|
7160
|
+
`${items.length} session${items.length > 1 ? 's' : ''}`,
|
|
7161
|
+
items.map(renderItem).join(''),
|
|
7162
|
+
);
|
|
7163
|
+
}
|
|
7164
|
+
if (orphans.length) html += _renderOrphanGroup(orphans.length, orphans.map(renderItem).join(''));
|
|
7165
|
+
return html;
|
|
7166
|
+
}
|
|
7167
|
+
|
|
7168
|
+
function _storageRestoreSession(id) {
|
|
7169
|
+
setSessionDismissed(id, false);
|
|
7170
|
+
_renderStorageTab();
|
|
7171
|
+
}
|
|
7172
|
+
|
|
7049
7173
|
async function _storageViewSession(id) {
|
|
7050
7174
|
closeStorageManager();
|
|
7051
7175
|
revealedStorageSessionId = id;
|
|
@@ -7276,6 +7400,76 @@ function _storageClearLinkedDocs(sessionId) {
|
|
|
7276
7400
|
afterLinkedDocsChanged(sessionId);
|
|
7277
7401
|
}
|
|
7278
7402
|
|
|
7403
|
+
async function _storageGroupsClean(group, dryRun) {
|
|
7404
|
+
const shown = await getJson('/api/sessions', null, { query: { filter: 'active', limit: 'all', lite: '1' } });
|
|
7405
|
+
if (!Array.isArray(shown)) throw new Error('could not list sessions');
|
|
7406
|
+
const res = await api('/api/groups/clean', {
|
|
7407
|
+
method: 'POST',
|
|
7408
|
+
body: { group, dryRun, keep: shown.map((s) => s.id), hide: [...dismissedSessionIds.keys()] },
|
|
7409
|
+
});
|
|
7410
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
|
7411
|
+
return res.json();
|
|
7412
|
+
}
|
|
7413
|
+
|
|
7414
|
+
async function _renderStorageGroups(body) {
|
|
7415
|
+
body.innerHTML = '<div class="storage-empty">Loading groups…</div>';
|
|
7416
|
+
let out;
|
|
7417
|
+
try {
|
|
7418
|
+
out = await _storageGroupsClean(null, true);
|
|
7419
|
+
} catch (e) {
|
|
7420
|
+
out = { error: e.message };
|
|
7421
|
+
}
|
|
7422
|
+
_showStorageGroups(body, out);
|
|
7423
|
+
}
|
|
7424
|
+
|
|
7425
|
+
function _showStorageGroups(body, out) {
|
|
7426
|
+
if (document.querySelector('.storage-tab.active')?.dataset.tab !== 'groups') return;
|
|
7427
|
+
document.getElementById('storage-groups-clean-btn').disabled = !out.groups?.some((g) => g.hidden);
|
|
7428
|
+
if (out.error) {
|
|
7429
|
+
body.innerHTML = `<div class="storage-empty">Could not load groups: ${escapeHtml(out.error)}</div>`;
|
|
7430
|
+
return;
|
|
7431
|
+
}
|
|
7432
|
+
if (!out.groups.length) {
|
|
7433
|
+
body.innerHTML = '<div class="storage-empty">No groups</div>';
|
|
7434
|
+
return;
|
|
7435
|
+
}
|
|
7436
|
+
const rows = out.groups
|
|
7437
|
+
.map((g) => {
|
|
7438
|
+
const badge = g.hidden ? ` <span class="storage-item-badge">${g.hidden} hidden</span>` : '';
|
|
7439
|
+
return `<div class="storage-group-header storage-group-row">
|
|
7440
|
+
<span>${escapeHtml(g.path)}${badge}</span>
|
|
7441
|
+
<div class="storage-item-actions">
|
|
7442
|
+
<span class="storage-item-meta">${g.members} member${g.members === 1 ? '' : 's'}</span>
|
|
7443
|
+
<button class="danger" title="Take out the hidden sessions, here and in the groups under it" onclick="_storageCleanGroup('${escAttrJs(g.id)}')">Clean</button>
|
|
7444
|
+
<button class="danger" onclick="_storageDeleteGroup('${escAttrJs(g.id)}')">Delete</button>
|
|
7445
|
+
</div>
|
|
7446
|
+
</div>`;
|
|
7447
|
+
})
|
|
7448
|
+
.join('');
|
|
7449
|
+
const hidden = out.groups.reduce((n, g) => n + g.hidden, 0);
|
|
7450
|
+
body.innerHTML = _renderProjectGroup('Groups', `${hidden} hidden`, rows);
|
|
7451
|
+
}
|
|
7452
|
+
|
|
7453
|
+
async function _storageCleanGroup(group) {
|
|
7454
|
+
let out;
|
|
7455
|
+
try {
|
|
7456
|
+
out = await _storageGroupsClean(group, false);
|
|
7457
|
+
} catch (e) {
|
|
7458
|
+
showToast(`Clean failed: ${e.message}`, 'error');
|
|
7459
|
+
return;
|
|
7460
|
+
}
|
|
7461
|
+
const n = out.dropped;
|
|
7462
|
+
showToast(n ? `Took ${n} hidden session${n === 1 ? '' : 's'} out of the groups` : 'No hidden sessions');
|
|
7463
|
+
_showStorageGroups(document.getElementById('storage-modal-body'), out);
|
|
7464
|
+
}
|
|
7465
|
+
|
|
7466
|
+
async function _storageDeleteGroup(id) {
|
|
7467
|
+
const group = sgGroupById(id);
|
|
7468
|
+
if (!group || !(await sgConfirmDelete(group))) return;
|
|
7469
|
+
await sgSaving;
|
|
7470
|
+
_renderStorageTab();
|
|
7471
|
+
}
|
|
7472
|
+
|
|
7279
7473
|
function _findOrphanedKeys(known) {
|
|
7280
7474
|
if (!known.size) return [];
|
|
7281
7475
|
const orphaned = [];
|
|
@@ -7386,15 +7580,17 @@ function dismissSessionFromList(sid) {
|
|
|
7386
7580
|
}
|
|
7387
7581
|
|
|
7388
7582
|
async function confirmDismissBlockers(sid) {
|
|
7389
|
-
const
|
|
7583
|
+
const pinState = getSessionPinState(sid);
|
|
7584
|
+
const pinned = pinState !== 'none';
|
|
7390
7585
|
const terminal = runningTerminals.has(sid);
|
|
7391
|
-
if (!
|
|
7586
|
+
if (!pinned && !terminal) return true;
|
|
7392
7587
|
const session = sessions.find((s) => s.id === sid);
|
|
7393
7588
|
const name = session ? sessionDisplayName(session) : sid.slice(0, 8);
|
|
7394
|
-
const
|
|
7395
|
-
const
|
|
7589
|
+
const pinLabel = pinState === 'sticky' ? 'sticky pin' : 'pin';
|
|
7590
|
+
const has = [pinned && `a ${pinLabel}`, terminal && 'a running terminal'].filter(Boolean).join(' and ');
|
|
7591
|
+
const okLabel = pinned && terminal ? 'Unpin, end and dismiss' : pinned ? 'Unpin and dismiss' : 'End and dismiss';
|
|
7396
7592
|
const steps = [
|
|
7397
|
-
|
|
7593
|
+
pinned && `Remove the ${pinLabel}`,
|
|
7398
7594
|
terminal && 'End the terminal',
|
|
7399
7595
|
'Hide the session from the sidebar',
|
|
7400
7596
|
].filter(Boolean);
|
|
@@ -7405,7 +7601,7 @@ async function confirmDismissBlockers(sid) {
|
|
|
7405
7601
|
okLabel,
|
|
7406
7602
|
});
|
|
7407
7603
|
if (!ok) return false;
|
|
7408
|
-
if (
|
|
7604
|
+
if (isAnyPinned(sid)) unpinSession(sid);
|
|
7409
7605
|
if (terminal) closeTerminalSession(sid);
|
|
7410
7606
|
return true;
|
|
7411
7607
|
}
|
|
@@ -7504,6 +7700,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
7504
7700
|
} else if (e.key === '?' && document.getElementById('help-modal').classList.contains('visible')) {
|
|
7505
7701
|
e.preventDefault();
|
|
7506
7702
|
closeHelpModal();
|
|
7703
|
+
} else if (
|
|
7704
|
+
matchKey(e, 'ArrowLeft', 'ArrowRight') &&
|
|
7705
|
+
[...document.querySelectorAll('.modal-overlay.visible')].at(-1)?.id === 'storage-modal'
|
|
7706
|
+
) {
|
|
7707
|
+
e.preventDefault();
|
|
7708
|
+
stepStorageTab(matchKey(e, 'ArrowLeft') ? -1 : 1);
|
|
7507
7709
|
} else if (shiftOnly(e, 'KeyM') && document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
7508
7710
|
e.preventDefault();
|
|
7509
7711
|
closeMsgDetailModal();
|
|
@@ -7581,11 +7783,15 @@ document.addEventListener('keydown', (e) => {
|
|
|
7581
7783
|
}
|
|
7582
7784
|
if (matchKey(e, '[', ']') && cyclePaneStrip(e.key === ']' ? 1 : -1)) {
|
|
7583
7785
|
e.preventDefault();
|
|
7786
|
+
focusActivePane();
|
|
7584
7787
|
return;
|
|
7585
7788
|
}
|
|
7586
7789
|
const altOnly = e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey;
|
|
7587
7790
|
if (altOnly && (e.code === 'BracketLeft' || e.code === 'BracketRight')) {
|
|
7588
|
-
if (cyclePane(e.code === 'BracketRight' ? 1 : -1))
|
|
7791
|
+
if (cyclePane(e.code === 'BracketRight' ? 1 : -1)) {
|
|
7792
|
+
e.preventDefault();
|
|
7793
|
+
focusActivePane();
|
|
7794
|
+
}
|
|
7589
7795
|
return;
|
|
7590
7796
|
}
|
|
7591
7797
|
if (altOnly && e.code === 'KeyW' && closeFocusedPane()) {
|
|
@@ -7603,6 +7809,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
7603
7809
|
}
|
|
7604
7810
|
if (plainShift && (e.key === '{' || e.key === '}') && focusPaneSide(e.key === '{' ? 'l' : 'r')) {
|
|
7605
7811
|
e.preventDefault();
|
|
7812
|
+
focusActivePane();
|
|
7606
7813
|
return;
|
|
7607
7814
|
}
|
|
7608
7815
|
if (shiftOnly(e, 'KeyL')) {
|
|
@@ -7650,8 +7857,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
7650
7857
|
|
|
7651
7858
|
// Tab toggles focus zone
|
|
7652
7859
|
if (e.key === 'Tab' && detailPanel.contains(e.target)) return;
|
|
7860
|
+
// A focused pane document keeps the arrow, page and space keys for scrolling.
|
|
7861
|
+
const inPane = paneViews.contains(e.target);
|
|
7862
|
+
if (inPane && e.key !== 'Tab' && e.key !== 'Escape') return;
|
|
7653
7863
|
if (e.key === 'Tab') {
|
|
7654
7864
|
e.preventDefault();
|
|
7865
|
+
if (inPane) e.target.blur();
|
|
7655
7866
|
// The terminal replaces the board, so it takes the board's place in the Tab cycle.
|
|
7656
7867
|
if (wantsTerminal()) {
|
|
7657
7868
|
if (focusZone === 'sidebar') focusTerminalPane();
|
|
@@ -7732,6 +7943,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
7732
7943
|
deleteTask(selectedTaskId, selectedSessionId || currentSessionId);
|
|
7733
7944
|
return;
|
|
7734
7945
|
}
|
|
7946
|
+
|
|
7947
|
+
if (matchKey(e, 'KeyE') && detailPanel.classList.contains('visible')) {
|
|
7948
|
+
e.preventDefault();
|
|
7949
|
+
setDetailExpanded(!detailPanel.classList.contains('expanded'));
|
|
7950
|
+
return;
|
|
7951
|
+
}
|
|
7735
7952
|
}
|
|
7736
7953
|
|
|
7737
7954
|
if (e.key === 'Escape') {
|
|
@@ -7851,6 +8068,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
7851
8068
|
// the frame keeps its own ranges and marks them by id. A page whose CSP forbids inline
|
|
7852
8069
|
// scripts gets no comments.
|
|
7853
8070
|
const reviewDrafts = new Map();
|
|
8071
|
+
// Text typed but not added, kept across a teardown the user did not ask for (the content
|
|
8072
|
+
// refreshed, a modal opened on top) until its review is unmounted.
|
|
8073
|
+
const reviewUnsent = new Map();
|
|
7854
8074
|
const reviewHighlight = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
|
|
7855
8075
|
const reviewHotHighlight = reviewHighlight ? new Highlight() : null;
|
|
7856
8076
|
const reviewPendingHighlight = reviewHighlight ? new Highlight() : null;
|
|
@@ -7907,9 +8127,13 @@ function dropReviewEntry(panelEl) {
|
|
|
7907
8127
|
|
|
7908
8128
|
function showTopReview() {
|
|
7909
8129
|
const opts = reviewStack.at(-1) || reviewBase;
|
|
7910
|
-
if (activeReview
|
|
7911
|
-
|
|
7912
|
-
|
|
8130
|
+
if (!activeReview || activeReview.opts !== opts) {
|
|
8131
|
+
stopReview();
|
|
8132
|
+
if (opts) startReview(opts);
|
|
8133
|
+
}
|
|
8134
|
+
if (!reviewUnsent.size) return;
|
|
8135
|
+
const mounted = new Set([...reviewStack, reviewBase].filter(Boolean).map((o) => reviewKey(o.sessionId, o.source)));
|
|
8136
|
+
for (const key of reviewUnsent.keys()) if (!mounted.has(key)) reviewUnsent.delete(key);
|
|
7913
8137
|
}
|
|
7914
8138
|
|
|
7915
8139
|
function startReview(opts) {
|
|
@@ -7943,11 +8167,17 @@ function startReview(opts) {
|
|
|
7943
8167
|
document.addEventListener('scroll', closeReviewOffer, { capture: true, signal });
|
|
7944
8168
|
panelEl.addEventListener('click', onReviewPanelClick, { signal });
|
|
7945
8169
|
panelEl.addEventListener('mouseover', onReviewPanelHover, { signal });
|
|
8170
|
+
panelEl.addEventListener('dblclick', onReviewPanelDblClick, { signal });
|
|
8171
|
+
panelEl.addEventListener('keydown', onReviewPanelKey, { signal });
|
|
8172
|
+
panelEl.addEventListener('input', onReviewPanelInput, { signal });
|
|
7946
8173
|
panelEl.addEventListener('mouseleave', () => setReviewHot(null), { signal });
|
|
8174
|
+
if (!frame) anchorReviewItems(contentEl);
|
|
7947
8175
|
renderReviewPanel();
|
|
8176
|
+
restoreUnsentReview();
|
|
7948
8177
|
}
|
|
7949
8178
|
|
|
7950
8179
|
function stopReview() {
|
|
8180
|
+
if (activeReview) keepUnsentReview();
|
|
7951
8181
|
closeReviewPop();
|
|
7952
8182
|
if (!activeReview) return;
|
|
7953
8183
|
const { ctl, panelEl, hostEl } = activeReview;
|
|
@@ -8033,6 +8263,100 @@ function reviewSelection() {
|
|
|
8033
8263
|
return { quote, range: range.cloneRange(), rect: range.getBoundingClientRect() };
|
|
8034
8264
|
}
|
|
8035
8265
|
|
|
8266
|
+
// A re-render replaces the nodes a range points at, so a comment keeps its text and the
|
|
8267
|
+
// 32 chars around it, and is found again after a refresh. Port of scratchpad's cmtFindAnchor.
|
|
8268
|
+
function reviewAnchorOf(root, range) {
|
|
8269
|
+
const before = reviewTextBefore(root, range.startContainer, range.startOffset);
|
|
8270
|
+
const after = root.ownerDocument.createRange();
|
|
8271
|
+
after.selectNodeContents(root);
|
|
8272
|
+
after.setStart(range.endContainer, range.endOffset);
|
|
8273
|
+
return {
|
|
8274
|
+
exact: range.toString(),
|
|
8275
|
+
pos: before.length,
|
|
8276
|
+
prefix: before.slice(-32),
|
|
8277
|
+
suffix: after.toString().slice(0, 32),
|
|
8278
|
+
};
|
|
8279
|
+
}
|
|
8280
|
+
|
|
8281
|
+
// The best match scores the most prefix and suffix chars that match outward from the quote;
|
|
8282
|
+
// a tie goes to the one nearest the old offset. -1 when the quote is gone.
|
|
8283
|
+
function reviewQuoteAt(text, { exact, pos, prefix, suffix }) {
|
|
8284
|
+
let best = -1;
|
|
8285
|
+
let bestScore = -1;
|
|
8286
|
+
if (!exact) return best;
|
|
8287
|
+
for (let at = text.indexOf(exact); at !== -1; at = text.indexOf(exact, at + 1)) {
|
|
8288
|
+
let score = 0;
|
|
8289
|
+
for (let k = 1; k <= prefix.length && text[at - k] === prefix[prefix.length - k]; k++) score++;
|
|
8290
|
+
for (let k = 0; k < suffix.length && text[at + exact.length + k] === suffix[k]; k++) score++;
|
|
8291
|
+
if (score > bestScore || (score === bestScore && Math.abs(at - pos) < Math.abs(best - pos))) {
|
|
8292
|
+
best = at;
|
|
8293
|
+
bestScore = score;
|
|
8294
|
+
}
|
|
8295
|
+
}
|
|
8296
|
+
return best;
|
|
8297
|
+
}
|
|
8298
|
+
|
|
8299
|
+
function reviewTextIndex(root) {
|
|
8300
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
8301
|
+
const nodes = [];
|
|
8302
|
+
let text = '';
|
|
8303
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
8304
|
+
nodes.push({ n, start: text.length });
|
|
8305
|
+
text += n.nodeValue;
|
|
8306
|
+
}
|
|
8307
|
+
return { nodes, text };
|
|
8308
|
+
}
|
|
8309
|
+
|
|
8310
|
+
function reviewFindAnchor({ nodes, text }, anchor) {
|
|
8311
|
+
const at = reviewQuoteAt(text, anchor);
|
|
8312
|
+
if (at < 0) return null;
|
|
8313
|
+
const end = at + anchor.exact.length;
|
|
8314
|
+
const first = nodes.findLast((x) => x.start <= at);
|
|
8315
|
+
const last = nodes.find((x) => end <= x.start + x.n.nodeValue.length);
|
|
8316
|
+
const range = document.createRange();
|
|
8317
|
+
range.setStart(first.n, at - first.start);
|
|
8318
|
+
range.setEnd(last.n, end - last.start);
|
|
8319
|
+
return { range, at };
|
|
8320
|
+
}
|
|
8321
|
+
|
|
8322
|
+
function anchorReviewItems(root) {
|
|
8323
|
+
const items = reviewItems().filter((c) => c.anchor);
|
|
8324
|
+
if (!items.length) return;
|
|
8325
|
+
const index = reviewTextIndex(root);
|
|
8326
|
+
for (const c of items) {
|
|
8327
|
+
const found = reviewFindAnchor(index, c.anchor);
|
|
8328
|
+
c.range = found?.range || null;
|
|
8329
|
+
if (!found) continue;
|
|
8330
|
+
c.pos = c.anchor.pos = found.at;
|
|
8331
|
+
reviewHighlight?.add(c.range);
|
|
8332
|
+
}
|
|
8333
|
+
}
|
|
8334
|
+
|
|
8335
|
+
function keepUnsentReview() {
|
|
8336
|
+
const { key, picked, editing } = activeReview;
|
|
8337
|
+
const text = reviewPopMode === 'editor' ? reviewPopEl().querySelector('textarea').value : '';
|
|
8338
|
+
if (text.trim()) reviewUnsent.set(key, { picked, text });
|
|
8339
|
+
else if (editing) reviewUnsent.set(key, { editing });
|
|
8340
|
+
}
|
|
8341
|
+
|
|
8342
|
+
function restoreUnsentReview() {
|
|
8343
|
+
const u = reviewUnsent.get(activeReview.key);
|
|
8344
|
+
if (!u) return;
|
|
8345
|
+
reviewUnsent.delete(activeReview.key);
|
|
8346
|
+
if (u.editing) {
|
|
8347
|
+
if (reviewItems().includes(u.editing.c)) startReviewEdit(u.editing.c, u.editing.draft);
|
|
8348
|
+
return;
|
|
8349
|
+
}
|
|
8350
|
+
const { anchor } = u.picked;
|
|
8351
|
+
const range = anchor && reviewFindAnchor(reviewTextIndex(activeReview.contentEl), anchor)?.range;
|
|
8352
|
+
if (range) return openReviewEditor({ ...u.picked, range, rect: range.getBoundingClientRect() }, u.text);
|
|
8353
|
+
const why = anchor ? 'The text you commented on changed' : 'The page reloaded';
|
|
8354
|
+
showToast(`${why}, so the comment was not added`, 'info', {
|
|
8355
|
+
label: 'Copy',
|
|
8356
|
+
onClick: () => copyText(u.text, 'the comment'),
|
|
8357
|
+
});
|
|
8358
|
+
}
|
|
8359
|
+
|
|
8036
8360
|
function onReviewFrameMessage(e) {
|
|
8037
8361
|
const frameWin = activeReview?.frame?.contentWindow;
|
|
8038
8362
|
const type =
|
|
@@ -8368,31 +8692,45 @@ function isReviewSendKey(e) {
|
|
|
8368
8692
|
return !e.target.closest?.(TEXT_FIELD_SELECTOR);
|
|
8369
8693
|
}
|
|
8370
8694
|
|
|
8371
|
-
function openReviewEditor(picked) {
|
|
8695
|
+
function openReviewEditor(picked, text = '') {
|
|
8372
8696
|
const el = reviewPopEl();
|
|
8373
8697
|
reviewPopMode = 'editor';
|
|
8374
8698
|
el.className = 'visible editor';
|
|
8375
|
-
if (picked.range)
|
|
8376
|
-
|
|
8699
|
+
if (picked.range) {
|
|
8700
|
+
picked.anchor = reviewAnchorOf(activeReview.contentEl, picked.range);
|
|
8701
|
+
reviewPendingHighlight?.add(picked.range);
|
|
8702
|
+
} else if (picked.inFrame) postToReviewFrame('pend');
|
|
8703
|
+
activeReview.picked = picked;
|
|
8377
8704
|
el.innerHTML = `<textarea rows="3" placeholder="Comment (Ctrl+Enter to add)"></textarea>
|
|
8378
8705
|
<div class="review-pop-actions"><button type="button" data-act="cancel">Cancel</button><button type="button" data-act="add" class="primary">Add</button></div>`;
|
|
8379
8706
|
const ta = el.querySelector('textarea');
|
|
8380
|
-
|
|
8381
|
-
|
|
8382
|
-
|
|
8383
|
-
addReviewComment(picked, comment);
|
|
8384
|
-
};
|
|
8385
|
-
ta.addEventListener('keydown', (e) => {
|
|
8386
|
-
e.stopPropagation();
|
|
8387
|
-
if (e.key === 'Escape') closeReviewPop();
|
|
8388
|
-
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) add();
|
|
8389
|
-
});
|
|
8707
|
+
ta.value = text;
|
|
8708
|
+
const add = (comment) => addReviewComment(picked, comment);
|
|
8709
|
+
ta.addEventListener('keydown', (e) => onReviewEditorKey(e, add, closeReviewPop));
|
|
8390
8710
|
el.querySelector('[data-act="cancel"]').addEventListener('click', closeReviewPop);
|
|
8391
|
-
el.querySelector('[data-act="add"]').addEventListener('click',
|
|
8711
|
+
el.querySelector('[data-act="add"]').addEventListener('click', () => {
|
|
8712
|
+
const comment = reviewEditorText(ta);
|
|
8713
|
+
if (comment) add(comment);
|
|
8714
|
+
});
|
|
8392
8715
|
placeReviewPop(el, picked.rect);
|
|
8393
8716
|
ta.focus();
|
|
8394
8717
|
}
|
|
8395
8718
|
|
|
8719
|
+
function reviewEditorText(ta) {
|
|
8720
|
+
const text = ta.value.trim();
|
|
8721
|
+
if (!text) ta.focus();
|
|
8722
|
+
return text;
|
|
8723
|
+
}
|
|
8724
|
+
|
|
8725
|
+
function onReviewEditorKey(e, save, cancel) {
|
|
8726
|
+
e.stopPropagation();
|
|
8727
|
+
if (e.key === 'Escape') cancel();
|
|
8728
|
+
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
8729
|
+
const text = reviewEditorText(e.target);
|
|
8730
|
+
if (text) save(text);
|
|
8731
|
+
}
|
|
8732
|
+
}
|
|
8733
|
+
|
|
8396
8734
|
function addReviewComment(picked, comment) {
|
|
8397
8735
|
if (!activeReview) return;
|
|
8398
8736
|
const { key, contentEl } = activeReview;
|
|
@@ -8413,8 +8751,8 @@ function addReviewComment(picked, comment) {
|
|
|
8413
8751
|
heading: reviewHeadingBefore(contentEl, range.startContainer),
|
|
8414
8752
|
...activeReview.context?.(range),
|
|
8415
8753
|
};
|
|
8416
|
-
const
|
|
8417
|
-
item = { quote: picked.quote, comment, context, pos, range, frameId: 0 };
|
|
8754
|
+
const { anchor } = picked;
|
|
8755
|
+
item = { quote: picked.quote, comment, context, pos: anchor.pos, anchor, range, frameId: 0 };
|
|
8418
8756
|
reviewHighlight?.add(range);
|
|
8419
8757
|
window.getSelection()?.removeAllRanges();
|
|
8420
8758
|
}
|
|
@@ -8444,6 +8782,7 @@ function removeReviewComment(c) {
|
|
|
8444
8782
|
items.splice(i, 1);
|
|
8445
8783
|
unmarkReviewItem(c);
|
|
8446
8784
|
if (activeReview.hot === c) setReviewHot(null);
|
|
8785
|
+
if (activeReview.editing?.c === c) activeReview.editing = null;
|
|
8447
8786
|
if (!items.length) reviewDrafts.delete(activeReview.key);
|
|
8448
8787
|
renderReviewPanel();
|
|
8449
8788
|
}
|
|
@@ -8451,6 +8790,7 @@ function removeReviewComment(c) {
|
|
|
8451
8790
|
function clearReviewDrafts(key = activeReview?.key) {
|
|
8452
8791
|
for (const c of reviewDrafts.get(key) || []) unmarkReviewItem(c);
|
|
8453
8792
|
reviewDrafts.delete(key);
|
|
8793
|
+
if (activeReview) activeReview.editing = null;
|
|
8454
8794
|
setReviewHot(null);
|
|
8455
8795
|
renderReviewPanel();
|
|
8456
8796
|
}
|
|
@@ -8459,16 +8799,52 @@ function onReviewPanelClick(e) {
|
|
|
8459
8799
|
const act = e.target.closest('[data-act]')?.dataset.act;
|
|
8460
8800
|
if (act === 'send') return sendReview();
|
|
8461
8801
|
if (act === 'discard') return clearReviewDrafts();
|
|
8462
|
-
const
|
|
8463
|
-
|
|
8464
|
-
if (!c) return;
|
|
8802
|
+
const c = reviewCardItem(e.target);
|
|
8803
|
+
if (!c || e.target.closest('.review-card-edit')) return;
|
|
8465
8804
|
if (e.target.closest('.review-remove')) removeReviewComment(c);
|
|
8805
|
+
else if (e.target.closest('.review-edit')) startReviewEdit(c);
|
|
8466
8806
|
else revealReviewItem(c);
|
|
8467
8807
|
}
|
|
8468
8808
|
|
|
8809
|
+
function reviewCardItem(el) {
|
|
8810
|
+
const card = el.closest('.review-card');
|
|
8811
|
+
return (card && reviewItems()[Number(card.dataset.i)]) || null;
|
|
8812
|
+
}
|
|
8813
|
+
|
|
8814
|
+
function onReviewPanelDblClick(e) {
|
|
8815
|
+
const c = reviewCardItem(e.target);
|
|
8816
|
+
if (c && e.target.closest('.review-card-comment')) startReviewEdit(c);
|
|
8817
|
+
}
|
|
8818
|
+
|
|
8819
|
+
// `draft` keeps the typed text when the panel renders again during the edit.
|
|
8820
|
+
function startReviewEdit(c, draft = c.comment) {
|
|
8821
|
+
activeReview.editing = { c, draft };
|
|
8822
|
+
renderReviewPanel();
|
|
8823
|
+
const ta = activeReview.panelEl.querySelector('.review-card-edit');
|
|
8824
|
+
ta.focus();
|
|
8825
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
8826
|
+
}
|
|
8827
|
+
|
|
8828
|
+
function endReviewEdit() {
|
|
8829
|
+
activeReview.editing = null;
|
|
8830
|
+
renderReviewPanel();
|
|
8831
|
+
}
|
|
8832
|
+
|
|
8833
|
+
function saveReviewEdit(comment) {
|
|
8834
|
+
activeReview.editing.c.comment = comment;
|
|
8835
|
+
endReviewEdit();
|
|
8836
|
+
}
|
|
8837
|
+
|
|
8838
|
+
function onReviewPanelKey(e) {
|
|
8839
|
+
if (e.target.matches('.review-card-edit')) onReviewEditorKey(e, saveReviewEdit, endReviewEdit);
|
|
8840
|
+
}
|
|
8841
|
+
|
|
8842
|
+
function onReviewPanelInput(e) {
|
|
8843
|
+
if (e.target.matches('.review-card-edit')) activeReview.editing.draft = e.target.value;
|
|
8844
|
+
}
|
|
8845
|
+
|
|
8469
8846
|
function onReviewPanelHover(e) {
|
|
8470
|
-
|
|
8471
|
-
setReviewHot((card && reviewItems()[Number(card.dataset.i)]) || null);
|
|
8847
|
+
setReviewHot(reviewCardItem(e.target));
|
|
8472
8848
|
}
|
|
8473
8849
|
|
|
8474
8850
|
let reviewSendClaimed = false;
|
|
@@ -8495,10 +8871,15 @@ function renderReviewPanel() {
|
|
|
8495
8871
|
</div>
|
|
8496
8872
|
<ol class="review-panel-list">${items
|
|
8497
8873
|
.map((c, i) => {
|
|
8874
|
+
const body =
|
|
8875
|
+
c === activeReview.editing?.c
|
|
8876
|
+
? `<textarea class="review-card-edit" rows="3" aria-label="Edit comment" placeholder="Ctrl+Enter to save, Esc to cancel">${escapeHtml(activeReview.editing.draft)}</textarea>`
|
|
8877
|
+
: `<div class="review-card-comment" title="Double-click to edit">${escapeHtml(c.comment)}</div>`;
|
|
8498
8878
|
return `<li class="review-card${c === activeReview.hot ? ' hot' : ''}" data-i="${i}" title="Show in the document">
|
|
8879
|
+
<button type="button" class="review-edit" title="Edit" aria-label="Edit">${DETAIL_EDIT_SVG}</button>
|
|
8499
8880
|
<button type="button" class="review-remove" title="Remove" aria-label="Remove">×</button>
|
|
8500
|
-
<div class="review-card-quote">${escapeHtml(c.quote.slice(0, 300))}</div>
|
|
8501
|
-
|
|
8881
|
+
<div class="review-card-quote">${c.anchor && !c.range ? '<span class="review-orphan" title="The file changed and this text is no longer in it">not found</span>' : ''}${escapeHtml(c.quote.slice(0, 300))}</div>
|
|
8882
|
+
${body}</li>`;
|
|
8502
8883
|
})
|
|
8503
8884
|
.join('')}</ol>`;
|
|
8504
8885
|
}
|
|
@@ -8581,6 +8962,8 @@ async function sendPreviewAction(frame, msg) {
|
|
|
8581
8962
|
//#region MARKDOWN_PREVIEW
|
|
8582
8963
|
const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
|
|
8583
8964
|
let currentPreviewPath = null;
|
|
8965
|
+
// The session a CLI preview was sent for, which need not be the one on screen.
|
|
8966
|
+
let previewSessionId = null;
|
|
8584
8967
|
let previewFromTerminal = false;
|
|
8585
8968
|
|
|
8586
8969
|
function readStoredList(key) {
|
|
@@ -8802,8 +9185,9 @@ function localImageUrl(src, base) {
|
|
|
8802
9185
|
return `/api/preview/image?${new URLSearchParams({ path: p, base })}`;
|
|
8803
9186
|
}
|
|
8804
9187
|
|
|
8805
|
-
function openPreviewModal(filePath, content, kind) {
|
|
9188
|
+
function openPreviewModal(filePath, content, kind, sessionId) {
|
|
8806
9189
|
currentPreviewPath = filePath;
|
|
9190
|
+
previewSessionId = sessionId;
|
|
8807
9191
|
const fileName = filePath.split(/[\\/]/).pop();
|
|
8808
9192
|
document.getElementById('preview-modal-title').textContent = fileName;
|
|
8809
9193
|
const bodyEl = document.getElementById('preview-modal-body');
|
|
@@ -8821,7 +9205,13 @@ function openPreviewModal(filePath, content, kind) {
|
|
|
8821
9205
|
const panelEl = document.getElementById('preview-review-panel');
|
|
8822
9206
|
const opts = fileReviewOpts(filePath, kind, bodyEl, isHtml ? bodyEl.querySelector('iframe') : null);
|
|
8823
9207
|
if (!opts) return unmountReview(panelEl);
|
|
8824
|
-
mountReview({
|
|
9208
|
+
mountReview({
|
|
9209
|
+
...opts,
|
|
9210
|
+
sessionId,
|
|
9211
|
+
panelEl,
|
|
9212
|
+
hostEl: document.querySelector('#preview-modal .modal'),
|
|
9213
|
+
onSent: closePreviewModal,
|
|
9214
|
+
});
|
|
8825
9215
|
}
|
|
8826
9216
|
|
|
8827
9217
|
// Null for an image, which takes no comments.
|
|
@@ -8841,35 +9231,35 @@ function sourceLineOf(bodyEl, range) {
|
|
|
8841
9231
|
return reviewTextBefore(code, range.startContainer, range.startOffset).split('\n').length;
|
|
8842
9232
|
}
|
|
8843
9233
|
|
|
8844
|
-
function
|
|
8845
|
-
if (!currentPreviewPath || !
|
|
9234
|
+
function isPreviewLinkedToSession() {
|
|
9235
|
+
if (!currentPreviewPath || !previewSessionId) return false;
|
|
8846
9236
|
const key = canonicalPath(currentPreviewPath);
|
|
8847
|
-
return getSessionPreviewPaths(
|
|
9237
|
+
return getSessionPreviewPaths(previewSessionId).some((p) => canonicalPath(p) === key);
|
|
8848
9238
|
}
|
|
8849
9239
|
|
|
8850
9240
|
function updatePreviewLinkBtn() {
|
|
8851
9241
|
const paneBtn = document.getElementById('preview-pane-btn');
|
|
8852
|
-
if (paneBtn) paneBtn.style.display =
|
|
9242
|
+
if (paneBtn) paneBtn.style.display = previewSessionId ? '' : 'none';
|
|
8853
9243
|
const btn = document.getElementById('preview-link-btn');
|
|
8854
9244
|
if (!btn) return;
|
|
8855
|
-
if (!
|
|
9245
|
+
if (!previewSessionId) {
|
|
8856
9246
|
btn.style.display = 'none';
|
|
8857
9247
|
return;
|
|
8858
9248
|
}
|
|
8859
9249
|
btn.style.display = '';
|
|
8860
|
-
const linked =
|
|
8861
|
-
btn.title = linked ? 'Unlink from
|
|
9250
|
+
const linked = isPreviewLinkedToSession();
|
|
9251
|
+
btn.title = linked ? 'Unlink from the session' : 'Link to the session';
|
|
8862
9252
|
btn.style.color = linked ? 'var(--accent)' : '';
|
|
8863
9253
|
}
|
|
8864
9254
|
|
|
8865
9255
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
8866
9256
|
function togglePreviewSessionLink() {
|
|
8867
|
-
if (!currentPreviewPath || !
|
|
9257
|
+
if (!currentPreviewPath || !previewSessionId) {
|
|
8868
9258
|
showToast('Select a session first');
|
|
8869
9259
|
return;
|
|
8870
9260
|
}
|
|
8871
|
-
const unlink =
|
|
8872
|
-
setSessionDocLink(
|
|
9261
|
+
const unlink = isPreviewLinkedToSession();
|
|
9262
|
+
setSessionDocLink(previewSessionId, currentPreviewPath, unlink);
|
|
8873
9263
|
showToast(unlink ? 'Unlinked from session' : 'Linked to session');
|
|
8874
9264
|
}
|
|
8875
9265
|
|
|
@@ -8896,6 +9286,7 @@ function closePreviewModal() {
|
|
|
8896
9286
|
// Empty the body so an iframe preview is destroyed and its scripts/timers stop.
|
|
8897
9287
|
document.getElementById('preview-modal-body').innerHTML = '';
|
|
8898
9288
|
currentPreviewPath = null;
|
|
9289
|
+
previewSessionId = null;
|
|
8899
9290
|
if (previewFromTerminal) focusTerminalPane();
|
|
8900
9291
|
previewFromTerminal = false;
|
|
8901
9292
|
}
|
|
@@ -8913,7 +9304,8 @@ function openPreviewInEditor() {
|
|
|
8913
9304
|
// The server is the only place that knows what it can render, so a null `kind` is its
|
|
8914
9305
|
// answer to "not previewable" rather than a failure: callers that have a fallback
|
|
8915
9306
|
// destination pass `onUnsupported` instead of keeping a copy of the extension list here.
|
|
8916
|
-
|
|
9307
|
+
// A link followed inside an open preview keeps the preview's session.
|
|
9308
|
+
async function openPreviewByPath(filePath, base, onUnsupported, sessionId = previewSessionId || currentSessionId) {
|
|
8917
9309
|
if (!filePath) return;
|
|
8918
9310
|
try {
|
|
8919
9311
|
const r = await api('/api/preview', { query: { path: filePath, base: base || null } });
|
|
@@ -8931,7 +9323,7 @@ async function openPreviewByPath(filePath, base, onUnsupported) {
|
|
|
8931
9323
|
else showToast('Nothing to preview in this file');
|
|
8932
9324
|
return;
|
|
8933
9325
|
}
|
|
8934
|
-
openPreviewModal(data.path, data.content, data.kind);
|
|
9326
|
+
openPreviewModal(data.path, data.content, data.kind, sessionId);
|
|
8935
9327
|
} catch {
|
|
8936
9328
|
showToast('Failed to load preview', 'error');
|
|
8937
9329
|
}
|
|
@@ -8970,7 +9362,7 @@ async function handlePreviewOpenEvent(data) {
|
|
|
8970
9362
|
}
|
|
8971
9363
|
}
|
|
8972
9364
|
// The broadcast carries the path only — each tab fetches the document itself.
|
|
8973
|
-
openPreviewByPath(filePath);
|
|
9365
|
+
openPreviewByPath(filePath, undefined, undefined, currentSessionId);
|
|
8974
9366
|
}
|
|
8975
9367
|
|
|
8976
9368
|
// Linked docs live in localStorage, so the CLI can only reach them through a tab:
|
|
@@ -9684,7 +10076,8 @@ function setupEventSource() {
|
|
|
9684
10076
|
if (!skipOffScreen()) refreshOpenPlan();
|
|
9685
10077
|
}
|
|
9686
10078
|
|
|
9687
|
-
if (data.type === 'terminals-update' && Array.isArray(data.ids))
|
|
10079
|
+
if (data.type === 'terminals-update' && Array.isArray(data.ids))
|
|
10080
|
+
setRunningTerminals(data.ids, data.awaitingTrust);
|
|
9688
10081
|
|
|
9689
10082
|
if (data.type === 'terminal:ended' && typeof data.id === 'string') onTerminalEnded(data.id);
|
|
9690
10083
|
|
|
@@ -10347,9 +10740,8 @@ function isSessionActive(s) {
|
|
|
10347
10740
|
return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
|
|
10348
10741
|
}
|
|
10349
10742
|
|
|
10350
|
-
// An active pinned session renders outside the Pinned group until the user dismisses it.
|
|
10351
10743
|
function isInPinnedGroup(s) {
|
|
10352
|
-
return isPlacedPinned(s.id) && !isPlacedSticky(s.id)
|
|
10744
|
+
return isPlacedPinned(s.id) && !isPlacedSticky(s.id);
|
|
10353
10745
|
}
|
|
10354
10746
|
|
|
10355
10747
|
const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -11046,24 +11438,32 @@ function renderFilterState() {
|
|
|
11046
11438
|
document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
|
|
11047
11439
|
}
|
|
11048
11440
|
|
|
11441
|
+
// With the sidebar cursor on a group header, zen shows that group instead of the open session.
|
|
11049
11442
|
function toggleZenMode() {
|
|
11050
|
-
|
|
11443
|
+
const leavingSessionZen = zenMode;
|
|
11444
|
+
if (zenMode) zenMode = false;
|
|
11445
|
+
else if (zenGroup) zenGroup = null;
|
|
11446
|
+
else {
|
|
11447
|
+
const header = focusZone === 'sidebar' ? sessionsList.querySelector('.session-group-header.kb-selected') : null;
|
|
11448
|
+
zenGroup = header?.dataset.groupId || null;
|
|
11449
|
+
zenMode = !zenGroup;
|
|
11450
|
+
}
|
|
11051
11451
|
store.setItem(ZEN_KEY, String(zenMode));
|
|
11052
11452
|
renderZenState();
|
|
11053
|
-
if (zenMode) closeFilterMenu();
|
|
11453
|
+
if (zenMode || zenGroup) closeFilterMenu();
|
|
11054
11454
|
renderSessions();
|
|
11055
|
-
// The open session may be outside the current server page/filter
|
|
11056
|
-
|
|
11455
|
+
// The open session or the group's members may be outside the current server page/filter,
|
|
11456
|
+
// and leaving group zen needs the filtered page back.
|
|
11457
|
+
if (!leavingSessionZen) fetchSessions(false);
|
|
11057
11458
|
}
|
|
11058
11459
|
|
|
11059
11460
|
function renderZenState() {
|
|
11060
11461
|
const btn = document.getElementById('zen-mode-btn');
|
|
11061
11462
|
if (!btn) return;
|
|
11062
|
-
|
|
11063
|
-
btn.
|
|
11064
|
-
btn.
|
|
11065
|
-
|
|
11066
|
-
: 'Zen mode: current session only (Ctrl+Shift+Z)';
|
|
11463
|
+
const on = zenMode || !!zenGroup;
|
|
11464
|
+
btn.classList.toggle('active', on);
|
|
11465
|
+
btn.setAttribute('aria-pressed', String(on));
|
|
11466
|
+
btn.title = on ? 'Zen mode on — show all sessions (Ctrl+Shift+Z)' : 'Zen mode: current session only (Ctrl+Shift+Z)';
|
|
11067
11467
|
}
|
|
11068
11468
|
|
|
11069
11469
|
function filterBySessions(value) {
|
|
@@ -11178,46 +11578,23 @@ document.addEventListener('click', (e) => {
|
|
|
11178
11578
|
if (sgHeader) {
|
|
11179
11579
|
if (e.target.closest('.sg-name-input')) return;
|
|
11180
11580
|
e.stopPropagation();
|
|
11581
|
+
const groupId = sgHeader.dataset.groupId;
|
|
11582
|
+
const group = sgGroupById(groupId);
|
|
11583
|
+
if (!group) return;
|
|
11181
11584
|
if (e.target.closest('.sg-keep')) {
|
|
11182
|
-
|
|
11585
|
+
sgKeep(group);
|
|
11586
|
+
persistSessionGroups();
|
|
11183
11587
|
renderSessions();
|
|
11184
11588
|
return;
|
|
11185
11589
|
}
|
|
11186
|
-
if (
|
|
11187
|
-
|
|
11188
|
-
|
|
11189
|
-
|
|
11190
|
-
|
|
11191
|
-
|
|
11192
|
-
okLabel: 'Delete group',
|
|
11193
|
-
}).then((ok) => {
|
|
11194
|
-
if (!ok) return;
|
|
11195
|
-
sgDeleteTransient(name);
|
|
11196
|
-
renderSessions();
|
|
11197
|
-
});
|
|
11590
|
+
if (e.target.closest('.sg-rename')) {
|
|
11591
|
+
sgBeginRename(groupId);
|
|
11592
|
+
return;
|
|
11593
|
+
}
|
|
11594
|
+
if (e.target.closest('.sg-delete')) {
|
|
11595
|
+
sgConfirmDelete(group);
|
|
11198
11596
|
return;
|
|
11199
11597
|
}
|
|
11200
|
-
const groupId = sgHeader.dataset.groupId;
|
|
11201
|
-
const group = sgGroupById(groupId);
|
|
11202
|
-
if (group) {
|
|
11203
|
-
if (e.target.closest('.sg-rename')) {
|
|
11204
|
-
sgBeginRename(groupId);
|
|
11205
|
-
return;
|
|
11206
|
-
}
|
|
11207
|
-
if (e.target.closest('.sg-delete')) {
|
|
11208
|
-
confirmModal({
|
|
11209
|
-
title: 'Delete this group?',
|
|
11210
|
-
message: 'Its sessions and projects go back to Projects.',
|
|
11211
|
-
chip: group.name,
|
|
11212
|
-
okLabel: 'Delete group',
|
|
11213
|
-
}).then((ok) => {
|
|
11214
|
-
if (!ok) return;
|
|
11215
|
-
sgDeleteGroup(groupId);
|
|
11216
|
-
renderSessions();
|
|
11217
|
-
});
|
|
11218
|
-
return;
|
|
11219
|
-
}
|
|
11220
|
-
} else if (!sgHeader.dataset.transientGroup) return;
|
|
11221
11598
|
}
|
|
11222
11599
|
|
|
11223
11600
|
const header = e.target.closest(COLLAPSIBLE_HEADER_SELECTOR);
|
|
@@ -11572,7 +11949,7 @@ function updateStickyBtnState() {
|
|
|
11572
11949
|
const isSticky = stickySessionIds.has(_infoModalSessionId);
|
|
11573
11950
|
stickyBtn.style.display = '';
|
|
11574
11951
|
stickyBtn.classList.toggle('active', isSticky);
|
|
11575
|
-
stickyBtn.title = isSticky ? 'Remove sticky pin' : 'Sticky pin — always show
|
|
11952
|
+
stickyBtn.title = isSticky ? 'Remove sticky pin' : 'Sticky pin — always show in the list';
|
|
11576
11953
|
const svg = stickyBtn.querySelector('svg');
|
|
11577
11954
|
if (svg) svg.setAttribute('fill', isSticky ? 'currentColor' : 'none');
|
|
11578
11955
|
}
|
|
@@ -13165,7 +13542,9 @@ function mountPane(sid, pane) {
|
|
|
13165
13542
|
view.dataset.key = key;
|
|
13166
13543
|
paneViews.appendChild(view);
|
|
13167
13544
|
paneFrames.set(key, view);
|
|
13168
|
-
loadPaneView(sid, pane, view)
|
|
13545
|
+
loadPaneView(sid, pane, view).then(() => {
|
|
13546
|
+
if (paneFocusWanted === key && activePaneView() === view) focusActivePane();
|
|
13547
|
+
});
|
|
13169
13548
|
while (paneFrames.size > PANE_FRAME_LIMIT) {
|
|
13170
13549
|
const [oldKey, oldView] = paneFrames.entries().next().value;
|
|
13171
13550
|
paneFrames.delete(oldKey);
|
|
@@ -13226,6 +13605,10 @@ async function loadPaneView(sid, pane, view, scroll) {
|
|
|
13226
13605
|
: ['pane-frame modal-zoomable', data.content + REVIEW_BRIDGE_TAG + restore];
|
|
13227
13606
|
const frame = createPreviewFrame(cls, doc);
|
|
13228
13607
|
frame.title = pane.title;
|
|
13608
|
+
// Focus given before the document loads stays on the frame element, out of the document's keys.
|
|
13609
|
+
frame.addEventListener('load', () => {
|
|
13610
|
+
if (document.activeElement === frame) frame.contentWindow?.focus();
|
|
13611
|
+
});
|
|
13229
13612
|
view.appendChild(frame);
|
|
13230
13613
|
attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, frame, frame));
|
|
13231
13614
|
return;
|
|
@@ -13272,6 +13655,7 @@ function appendPaneDoc(view) {
|
|
|
13272
13655
|
doc.className = 'pane-doc';
|
|
13273
13656
|
const body = document.createElement('div');
|
|
13274
13657
|
body.className = 'pane-doc-body rendered-md modal-zoomable';
|
|
13658
|
+
body.tabIndex = -1;
|
|
13275
13659
|
doc.append(body, paneDocGrip(doc));
|
|
13276
13660
|
view.appendChild(doc);
|
|
13277
13661
|
return body;
|
|
@@ -13306,7 +13690,9 @@ let paneUpdatedToastTimer = 0;
|
|
|
13306
13690
|
async function autoReloadPaneView(sid, pane, view) {
|
|
13307
13691
|
if (!(await reloadPaneView(sid, pane, view))) return;
|
|
13308
13692
|
clearTimeout(paneUpdatedToastTimer);
|
|
13309
|
-
paneUpdatedToastTimer = setTimeout(() =>
|
|
13693
|
+
paneUpdatedToastTimer = setTimeout(() => {
|
|
13694
|
+
if (!document.getElementById('toast').classList.contains('has-action')) showToast('Pane updated', 'info');
|
|
13695
|
+
}, 1000);
|
|
13310
13696
|
}
|
|
13311
13697
|
|
|
13312
13698
|
// Builds the new view hidden over the old one and swaps them once it has loaded and scrolled,
|
|
@@ -13347,8 +13733,11 @@ async function reloadPaneView(sid, pane, view) {
|
|
|
13347
13733
|
paneReloads.delete(key);
|
|
13348
13734
|
fresh.className = view.className;
|
|
13349
13735
|
fresh.style.visibility = '';
|
|
13736
|
+
const hadFocus = view.contains(document.activeElement);
|
|
13350
13737
|
view.remove();
|
|
13351
13738
|
paneFrames.set(key, fresh);
|
|
13739
|
+
const target = hadFocus && paneFocusTarget(fresh);
|
|
13740
|
+
if (target) focusPaneTarget(target);
|
|
13352
13741
|
syncPaneReview();
|
|
13353
13742
|
return true;
|
|
13354
13743
|
}
|
|
@@ -13393,6 +13782,27 @@ function activePaneView() {
|
|
|
13393
13782
|
return wantsTerminal() ? null : paneViews.querySelector('.pane-view.on.pane-focus');
|
|
13394
13783
|
}
|
|
13395
13784
|
|
|
13785
|
+
// A URL page is on another origin and would keep every key from the board, so it gets no focus.
|
|
13786
|
+
function paneFocusTarget(view) {
|
|
13787
|
+
return view?.querySelector(':scope > .pane-doc > .pane-doc-body, :scope > .pane-frame:not(.pane-url-frame)');
|
|
13788
|
+
}
|
|
13789
|
+
|
|
13790
|
+
let paneFocusWanted = null;
|
|
13791
|
+
|
|
13792
|
+
// The key that switches panes also moves focus into the pane, so its content takes the scroll keys.
|
|
13793
|
+
function focusActivePane() {
|
|
13794
|
+
const view = activePaneView();
|
|
13795
|
+
const target = paneFocusTarget(view);
|
|
13796
|
+
paneFocusWanted = view && !view.firstElementChild ? view.dataset.key : null;
|
|
13797
|
+
if (target) focusPaneTarget(target);
|
|
13798
|
+
else if (paneViews.contains(document.activeElement)) document.activeElement.blur();
|
|
13799
|
+
}
|
|
13800
|
+
|
|
13801
|
+
function focusPaneTarget(target) {
|
|
13802
|
+
target.focus({ preventScroll: true });
|
|
13803
|
+
target.contentWindow?.focus();
|
|
13804
|
+
}
|
|
13805
|
+
|
|
13396
13806
|
function isPaneDocOnScreen() {
|
|
13397
13807
|
return !!activePaneView()?.querySelector('.modal-zoomable');
|
|
13398
13808
|
}
|
|
@@ -13792,15 +14202,13 @@ function markSourceLine(bodyEl, line) {
|
|
|
13792
14202
|
band.scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
13793
14203
|
}
|
|
13794
14204
|
|
|
13795
|
-
function openFileInPane(filePath, closeModal) {
|
|
13796
|
-
if (!filePath || !paneSessionId()) return;
|
|
13797
|
-
closeModal();
|
|
13798
|
-
addPane(filePath);
|
|
13799
|
-
}
|
|
13800
|
-
|
|
13801
14205
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
13802
14206
|
function openPreviewInPane() {
|
|
13803
|
-
|
|
14207
|
+
if (!currentPreviewPath || !previewSessionId) return;
|
|
14208
|
+
const sid = previewSessionId;
|
|
14209
|
+
const target = currentPreviewPath;
|
|
14210
|
+
closePreviewModal();
|
|
14211
|
+
addPaneToSession(sid, target, pathBasename(target));
|
|
13804
14212
|
}
|
|
13805
14213
|
|
|
13806
14214
|
// The plan modal also opens other sessions' plans; the pane goes to the plan's session, wherever it is.
|
|
@@ -13811,15 +14219,19 @@ function syncPlanPaneBtn() {
|
|
|
13811
14219
|
}
|
|
13812
14220
|
|
|
13813
14221
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
13814
|
-
|
|
14222
|
+
function openPlanInPane() {
|
|
13815
14223
|
if (!planPaneAllowed()) return;
|
|
13816
14224
|
const sid = _planSessionId;
|
|
13817
14225
|
const target = _pendingPlanPath;
|
|
13818
14226
|
closePlanModal();
|
|
14227
|
+
addPaneToSession(sid, target, 'Plan');
|
|
14228
|
+
}
|
|
14229
|
+
|
|
14230
|
+
async function addPaneToSession(sid, target, label) {
|
|
13819
14231
|
if (sid === paneSessionId()) return addPane(target);
|
|
13820
14232
|
if ((await postPane(sid, { target, base: getSessionBaseDir(sid) || undefined })) !== false) return;
|
|
13821
14233
|
const s = sessions.find((x) => x.id === sid);
|
|
13822
|
-
showToast(
|
|
14234
|
+
showToast(`${label} added to ${s ? sessionDisplayName(s) : 'its session'}`, 'success', {
|
|
13823
14235
|
label: 'Open',
|
|
13824
14236
|
onClick: () => openSession(sid),
|
|
13825
14237
|
});
|
|
@@ -13918,7 +14330,9 @@ paneStrip.addEventListener('click', (e) => {
|
|
|
13918
14330
|
return;
|
|
13919
14331
|
}
|
|
13920
14332
|
const tab = e.target.closest('.pane-tab');
|
|
13921
|
-
if (tab)
|
|
14333
|
+
if (!tab) return;
|
|
14334
|
+
selectPane(tab.dataset.pane);
|
|
14335
|
+
focusActivePane();
|
|
13922
14336
|
});
|
|
13923
14337
|
paneStrip.addEventListener('auxclick', (e) => {
|
|
13924
14338
|
const tab = e.target.closest('.pane-tab');
|
|
@@ -14288,6 +14702,7 @@ function toggleTerminal() {
|
|
|
14288
14702
|
setTerminalMode(currentSessionId, !terminalModes().has(currentSessionId));
|
|
14289
14703
|
termState.focusNext = true;
|
|
14290
14704
|
syncTerminal();
|
|
14705
|
+
if (!wantsTerminal()) focusActivePane();
|
|
14291
14706
|
}
|
|
14292
14707
|
|
|
14293
14708
|
// The show card sits in the pane but is not the terminal, so Alt+` from it goes to the terminal.
|
|
@@ -14352,7 +14767,7 @@ function setSwapPair(last, previous) {
|
|
|
14352
14767
|
async function swapToPreviousSession() {
|
|
14353
14768
|
const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
|
|
14354
14769
|
if (!target || target === currentSessionId) return;
|
|
14355
|
-
if (terminalPaneFocused()) termState.focusNext = true;
|
|
14770
|
+
if (terminalPaneFocused() || isShowCardFocused()) termState.focusNext = true;
|
|
14356
14771
|
await revealSession(target);
|
|
14357
14772
|
}
|
|
14358
14773
|
|
|
@@ -14451,9 +14866,12 @@ function takeTerminalFocus(sessionId) {
|
|
|
14451
14866
|
}
|
|
14452
14867
|
|
|
14453
14868
|
// The terminal attaches asynchronously; by then the user may be typing elsewhere or reading a modal.
|
|
14869
|
+
// A session on the trust dialog has no transcript yet, so it may not be in `sessions`.
|
|
14454
14870
|
function promptAwaitsUser(sessionId) {
|
|
14455
|
-
if (
|
|
14456
|
-
|
|
14871
|
+
if (isAnyModalOpen() || document.activeElement?.matches(TEXT_FIELD_SELECTOR)) return false;
|
|
14872
|
+
if (trustWaiting.has(sessionId)) return true;
|
|
14873
|
+
const session = sessions.find((s) => s.id === sessionId);
|
|
14874
|
+
return !!session && isWaitingSession(session);
|
|
14457
14875
|
}
|
|
14458
14876
|
|
|
14459
14877
|
function onTerminalShown(focus = true) {
|
|
@@ -14963,6 +15381,7 @@ async function openTerminal(sessionId, mode, attempt = 0) {
|
|
|
14963
15381
|
name: spec.name,
|
|
14964
15382
|
worktree: spec.worktree,
|
|
14965
15383
|
model: spec.model,
|
|
15384
|
+
effort: spec.effort,
|
|
14966
15385
|
prompt: spec.prompt,
|
|
14967
15386
|
edit: spec.edit,
|
|
14968
15387
|
forkOf: spec.forkOf,
|
|
@@ -16210,7 +16629,6 @@ function placeholderSession(id, spec) {
|
|
|
16210
16629
|
mode: spec.mode,
|
|
16211
16630
|
edit: !!spec.edit,
|
|
16212
16631
|
name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
16213
|
-
dispatchGroup: spec.group || undefined,
|
|
16214
16632
|
dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
|
|
16215
16633
|
project: spec.cwd,
|
|
16216
16634
|
worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
|
|
@@ -16229,7 +16647,22 @@ function mergePlaceholders(list) {
|
|
|
16229
16647
|
for (const id of [...specs.keys()]) if (ids.has(id)) specs.delete(id);
|
|
16230
16648
|
}
|
|
16231
16649
|
const pending = [...newSpecs, ...dispatchSpecs].sort((a, b) => b[1].startedAt - a[1].startedAt);
|
|
16232
|
-
|
|
16650
|
+
const placeholders = pending.map(([id, spec]) => placeholderSession(id, spec));
|
|
16651
|
+
revealNewPlaceholders(placeholders);
|
|
16652
|
+
return [...placeholders, ...list];
|
|
16653
|
+
}
|
|
16654
|
+
|
|
16655
|
+
// Ids whose sections were expanded once; a section the user collapses again stays collapsed.
|
|
16656
|
+
const revealedPlaceholderIds = new Set();
|
|
16657
|
+
|
|
16658
|
+
function revealNewPlaceholders(placeholders) {
|
|
16659
|
+
let changed = false;
|
|
16660
|
+
for (const p of placeholders) {
|
|
16661
|
+
if (revealedPlaceholderIds.has(p.id)) continue;
|
|
16662
|
+
revealedPlaceholderIds.add(p.id);
|
|
16663
|
+
if (uncollapseFor(p)) changed = true;
|
|
16664
|
+
}
|
|
16665
|
+
if (changed) persistCollapsedGroups();
|
|
16233
16666
|
}
|
|
16234
16667
|
|
|
16235
16668
|
async function loadDispatches() {
|
|
@@ -16283,10 +16716,14 @@ function renderPlaceholderCard(session, depth) {
|
|
|
16283
16716
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
16284
16717
|
const projectHtml = renderProjectIdentity(session);
|
|
16285
16718
|
return `
|
|
16286
|
-
<button onclick="
|
|
16719
|
+
<button onclick="openSession('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}${childDepthClass(depth)}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
|
|
16287
16720
|
<div class="session-name">${escapeHtml(session.name)}</div>
|
|
16288
16721
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
16289
|
-
|
|
16722
|
+
${
|
|
16723
|
+
trustWaiting.has(session.id)
|
|
16724
|
+
? `<div class="session-waiting"><span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>trust this folder?</div>`
|
|
16725
|
+
: `<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>`
|
|
16726
|
+
}
|
|
16290
16727
|
</button>
|
|
16291
16728
|
`;
|
|
16292
16729
|
}
|
|
@@ -16296,9 +16733,8 @@ async function restorePendingSessions() {
|
|
|
16296
16733
|
document.getElementById('new-session-btn').hidden = !terminalAvailable();
|
|
16297
16734
|
const terminals = terminalAvailable() ? loadTerminals() : null;
|
|
16298
16735
|
await loadDispatches();
|
|
16299
|
-
if (!terminals) return;
|
|
16300
16736
|
try {
|
|
16301
|
-
for (const t of await terminals) {
|
|
16737
|
+
for (const t of terminals ? await terminals : []) {
|
|
16302
16738
|
if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
|
|
16303
16739
|
newSpecs.set(t.id, {
|
|
16304
16740
|
cwd: t.cwd,
|
|
@@ -16311,6 +16747,8 @@ async function restorePendingSessions() {
|
|
|
16311
16747
|
});
|
|
16312
16748
|
}
|
|
16313
16749
|
} catch (_) {}
|
|
16750
|
+
// Sessions that were already running keep the collapse state saved before the reload.
|
|
16751
|
+
for (const id of [...newSpecs.keys(), ...dispatchSpecs.keys()]) revealedPlaceholderIds.add(id);
|
|
16314
16752
|
}
|
|
16315
16753
|
|
|
16316
16754
|
async function loadTerminals() {
|
|
@@ -16319,6 +16757,7 @@ async function loadTerminals() {
|
|
|
16319
16757
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
16320
16758
|
const list = data.sessions || [];
|
|
16321
16759
|
runningTerminals = new Set(list.map((t) => t.id));
|
|
16760
|
+
setTrustWaiting(list.filter((t) => t.awaitingTrust).map((t) => t.id));
|
|
16322
16761
|
rememberTerminalIds(list);
|
|
16323
16762
|
syncShowCard();
|
|
16324
16763
|
pushTerminalClaims();
|
|
@@ -16326,8 +16765,26 @@ async function loadTerminals() {
|
|
|
16326
16765
|
return list;
|
|
16327
16766
|
}
|
|
16328
16767
|
|
|
16329
|
-
|
|
16768
|
+
// A dispatched session never had its terminal toggled on, so the dialog would stay out of sight.
|
|
16769
|
+
function setTrustWaiting(ids) {
|
|
16770
|
+
const before = trustWaiting;
|
|
16771
|
+
trustWaiting = new Set(ids);
|
|
16772
|
+
for (const id of trustWaiting) {
|
|
16773
|
+
if (before.has(id)) continue;
|
|
16774
|
+
setTerminalMode(id, true);
|
|
16775
|
+
if (id !== currentSessionId || viewMode !== 'session') continue;
|
|
16776
|
+
if (termState.shown && termState.attached) {
|
|
16777
|
+
if (promptAwaitsUser(id)) focusTerminalPane();
|
|
16778
|
+
} else {
|
|
16779
|
+
termState.openedByUser = id;
|
|
16780
|
+
syncTerminal();
|
|
16781
|
+
}
|
|
16782
|
+
}
|
|
16783
|
+
}
|
|
16784
|
+
|
|
16785
|
+
function setRunningTerminals(ids, awaitingTrust = []) {
|
|
16330
16786
|
runningTerminals = new Set(ids);
|
|
16787
|
+
setTrustWaiting(awaitingTrust);
|
|
16331
16788
|
forgetEndedTerminals();
|
|
16332
16789
|
syncShowCard();
|
|
16333
16790
|
pushTerminalClaims();
|
|
@@ -16351,10 +16808,12 @@ function openNewSession(folder, resume = false) {
|
|
|
16351
16808
|
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt']) document.getElementById(id).value = '';
|
|
16352
16809
|
document.getElementById('ns-wt').checked = false;
|
|
16353
16810
|
document.getElementById('ns-model').value = '';
|
|
16811
|
+
document.getElementById('ns-effort').value = '';
|
|
16354
16812
|
document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
|
|
16355
16813
|
document.getElementById('ns-edit').checked = store.getItem(NEW_SESSION_EDIT_KEY) === 'true';
|
|
16356
16814
|
setNewSessionError('');
|
|
16357
|
-
const
|
|
16815
|
+
const session = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
16816
|
+
const current = session?.worktree?.repo || session?.project;
|
|
16358
16817
|
setNewSessionFolder(folder || current || '');
|
|
16359
16818
|
document.getElementById('new-session-modal').classList.add('visible');
|
|
16360
16819
|
const input = document.getElementById('ns-folder');
|
|
@@ -16458,6 +16917,7 @@ function newSessionValues() {
|
|
|
16458
16917
|
name: document.getElementById('ns-name').value.trim() || null,
|
|
16459
16918
|
worktree: wtOn ? wtName || true : false,
|
|
16460
16919
|
model: document.getElementById('ns-model').value || null,
|
|
16920
|
+
effort: document.getElementById('ns-effort').value || null,
|
|
16461
16921
|
prompt: document.getElementById('ns-prompt').value.trim() || null,
|
|
16462
16922
|
edit,
|
|
16463
16923
|
stay: !ns.resume && !edit && document.getElementById('ns-stay').checked,
|
|
@@ -16666,7 +17126,7 @@ function initNewSession() {
|
|
|
16666
17126
|
store.setItem(NEW_SESSION_EDIT_KEY, String(e.target.checked));
|
|
16667
17127
|
renderNewSessionForm();
|
|
16668
17128
|
});
|
|
16669
|
-
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
|
|
17129
|
+
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model', 'ns-effort']) {
|
|
16670
17130
|
document.getElementById(id).addEventListener('input', renderNewSessionForm);
|
|
16671
17131
|
}
|
|
16672
17132
|
if (SpeechRecognitionApi) {
|
|
@@ -16793,6 +17253,7 @@ loadModalFullscreen();
|
|
|
16793
17253
|
initModalResize();
|
|
16794
17254
|
loadPanelWidths();
|
|
16795
17255
|
initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width');
|
|
17256
|
+
initDetailLayout();
|
|
16796
17257
|
initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width');
|
|
16797
17258
|
initMessagePanelFull();
|
|
16798
17259
|
|