claude-code-kanban 7.7.0 → 7.8.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 +18 -47
- package/hub-app.json +2 -2
- package/install.js +11 -9
- package/lib/live-sessions.js +1 -1
- package/lib/terminal-client.js +4 -0
- package/lib/terminal-host.js +7 -1
- package/lib/terminal.js +61 -9
- package/lib/user-groups.js +86 -66
- package/package.json +2 -1
- package/public/app.js +486 -204
- package/public/group-tree.js +21 -0
- package/public/index.html +34 -13
- package/public/style.css +118 -6
- package/public/sw.js +1 -1
- package/public/vendor/claude-hub-sdk.js +1 -1
- package/server.js +72 -43
- 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') {
|
|
@@ -9684,7 +9901,8 @@ function setupEventSource() {
|
|
|
9684
9901
|
if (!skipOffScreen()) refreshOpenPlan();
|
|
9685
9902
|
}
|
|
9686
9903
|
|
|
9687
|
-
if (data.type === 'terminals-update' && Array.isArray(data.ids))
|
|
9904
|
+
if (data.type === 'terminals-update' && Array.isArray(data.ids))
|
|
9905
|
+
setRunningTerminals(data.ids, data.awaitingTrust);
|
|
9688
9906
|
|
|
9689
9907
|
if (data.type === 'terminal:ended' && typeof data.id === 'string') onTerminalEnded(data.id);
|
|
9690
9908
|
|
|
@@ -10347,9 +10565,8 @@ function isSessionActive(s) {
|
|
|
10347
10565
|
return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
|
|
10348
10566
|
}
|
|
10349
10567
|
|
|
10350
|
-
// An active pinned session renders outside the Pinned group until the user dismisses it.
|
|
10351
10568
|
function isInPinnedGroup(s) {
|
|
10352
|
-
return isPlacedPinned(s.id) && !isPlacedSticky(s.id)
|
|
10569
|
+
return isPlacedPinned(s.id) && !isPlacedSticky(s.id);
|
|
10353
10570
|
}
|
|
10354
10571
|
|
|
10355
10572
|
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 +11263,32 @@ function renderFilterState() {
|
|
|
11046
11263
|
document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
|
|
11047
11264
|
}
|
|
11048
11265
|
|
|
11266
|
+
// With the sidebar cursor on a group header, zen shows that group instead of the open session.
|
|
11049
11267
|
function toggleZenMode() {
|
|
11050
|
-
|
|
11268
|
+
const leavingSessionZen = zenMode;
|
|
11269
|
+
if (zenMode) zenMode = false;
|
|
11270
|
+
else if (zenGroup) zenGroup = null;
|
|
11271
|
+
else {
|
|
11272
|
+
const header = focusZone === 'sidebar' ? sessionsList.querySelector('.session-group-header.kb-selected') : null;
|
|
11273
|
+
zenGroup = header?.dataset.groupId || null;
|
|
11274
|
+
zenMode = !zenGroup;
|
|
11275
|
+
}
|
|
11051
11276
|
store.setItem(ZEN_KEY, String(zenMode));
|
|
11052
11277
|
renderZenState();
|
|
11053
|
-
if (zenMode) closeFilterMenu();
|
|
11278
|
+
if (zenMode || zenGroup) closeFilterMenu();
|
|
11054
11279
|
renderSessions();
|
|
11055
|
-
// The open session may be outside the current server page/filter
|
|
11056
|
-
|
|
11280
|
+
// The open session or the group's members may be outside the current server page/filter,
|
|
11281
|
+
// and leaving group zen needs the filtered page back.
|
|
11282
|
+
if (!leavingSessionZen) fetchSessions(false);
|
|
11057
11283
|
}
|
|
11058
11284
|
|
|
11059
11285
|
function renderZenState() {
|
|
11060
11286
|
const btn = document.getElementById('zen-mode-btn');
|
|
11061
11287
|
if (!btn) return;
|
|
11062
|
-
|
|
11063
|
-
btn.
|
|
11064
|
-
btn.
|
|
11065
|
-
|
|
11066
|
-
: 'Zen mode: current session only (Ctrl+Shift+Z)';
|
|
11288
|
+
const on = zenMode || !!zenGroup;
|
|
11289
|
+
btn.classList.toggle('active', on);
|
|
11290
|
+
btn.setAttribute('aria-pressed', String(on));
|
|
11291
|
+
btn.title = on ? 'Zen mode on — show all sessions (Ctrl+Shift+Z)' : 'Zen mode: current session only (Ctrl+Shift+Z)';
|
|
11067
11292
|
}
|
|
11068
11293
|
|
|
11069
11294
|
function filterBySessions(value) {
|
|
@@ -11178,46 +11403,23 @@ document.addEventListener('click', (e) => {
|
|
|
11178
11403
|
if (sgHeader) {
|
|
11179
11404
|
if (e.target.closest('.sg-name-input')) return;
|
|
11180
11405
|
e.stopPropagation();
|
|
11406
|
+
const groupId = sgHeader.dataset.groupId;
|
|
11407
|
+
const group = sgGroupById(groupId);
|
|
11408
|
+
if (!group) return;
|
|
11181
11409
|
if (e.target.closest('.sg-keep')) {
|
|
11182
|
-
|
|
11410
|
+
sgKeep(group);
|
|
11411
|
+
persistSessionGroups();
|
|
11183
11412
|
renderSessions();
|
|
11184
11413
|
return;
|
|
11185
11414
|
}
|
|
11186
|
-
if (
|
|
11187
|
-
|
|
11188
|
-
|
|
11189
|
-
|
|
11190
|
-
|
|
11191
|
-
|
|
11192
|
-
okLabel: 'Delete group',
|
|
11193
|
-
}).then((ok) => {
|
|
11194
|
-
if (!ok) return;
|
|
11195
|
-
sgDeleteTransient(name);
|
|
11196
|
-
renderSessions();
|
|
11197
|
-
});
|
|
11415
|
+
if (e.target.closest('.sg-rename')) {
|
|
11416
|
+
sgBeginRename(groupId);
|
|
11417
|
+
return;
|
|
11418
|
+
}
|
|
11419
|
+
if (e.target.closest('.sg-delete')) {
|
|
11420
|
+
sgConfirmDelete(group);
|
|
11198
11421
|
return;
|
|
11199
11422
|
}
|
|
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
11423
|
}
|
|
11222
11424
|
|
|
11223
11425
|
const header = e.target.closest(COLLAPSIBLE_HEADER_SELECTOR);
|
|
@@ -11572,7 +11774,7 @@ function updateStickyBtnState() {
|
|
|
11572
11774
|
const isSticky = stickySessionIds.has(_infoModalSessionId);
|
|
11573
11775
|
stickyBtn.style.display = '';
|
|
11574
11776
|
stickyBtn.classList.toggle('active', isSticky);
|
|
11575
|
-
stickyBtn.title = isSticky ? 'Remove sticky pin' : 'Sticky pin — always show
|
|
11777
|
+
stickyBtn.title = isSticky ? 'Remove sticky pin' : 'Sticky pin — always show in the list';
|
|
11576
11778
|
const svg = stickyBtn.querySelector('svg');
|
|
11577
11779
|
if (svg) svg.setAttribute('fill', isSticky ? 'currentColor' : 'none');
|
|
11578
11780
|
}
|
|
@@ -13165,7 +13367,9 @@ function mountPane(sid, pane) {
|
|
|
13165
13367
|
view.dataset.key = key;
|
|
13166
13368
|
paneViews.appendChild(view);
|
|
13167
13369
|
paneFrames.set(key, view);
|
|
13168
|
-
loadPaneView(sid, pane, view)
|
|
13370
|
+
loadPaneView(sid, pane, view).then(() => {
|
|
13371
|
+
if (paneFocusWanted === key && activePaneView() === view) focusActivePane();
|
|
13372
|
+
});
|
|
13169
13373
|
while (paneFrames.size > PANE_FRAME_LIMIT) {
|
|
13170
13374
|
const [oldKey, oldView] = paneFrames.entries().next().value;
|
|
13171
13375
|
paneFrames.delete(oldKey);
|
|
@@ -13226,6 +13430,10 @@ async function loadPaneView(sid, pane, view, scroll) {
|
|
|
13226
13430
|
: ['pane-frame modal-zoomable', data.content + REVIEW_BRIDGE_TAG + restore];
|
|
13227
13431
|
const frame = createPreviewFrame(cls, doc);
|
|
13228
13432
|
frame.title = pane.title;
|
|
13433
|
+
// Focus given before the document loads stays on the frame element, out of the document's keys.
|
|
13434
|
+
frame.addEventListener('load', () => {
|
|
13435
|
+
if (document.activeElement === frame) frame.contentWindow?.focus();
|
|
13436
|
+
});
|
|
13229
13437
|
view.appendChild(frame);
|
|
13230
13438
|
attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, frame, frame));
|
|
13231
13439
|
return;
|
|
@@ -13272,6 +13480,7 @@ function appendPaneDoc(view) {
|
|
|
13272
13480
|
doc.className = 'pane-doc';
|
|
13273
13481
|
const body = document.createElement('div');
|
|
13274
13482
|
body.className = 'pane-doc-body rendered-md modal-zoomable';
|
|
13483
|
+
body.tabIndex = -1;
|
|
13275
13484
|
doc.append(body, paneDocGrip(doc));
|
|
13276
13485
|
view.appendChild(doc);
|
|
13277
13486
|
return body;
|
|
@@ -13347,8 +13556,11 @@ async function reloadPaneView(sid, pane, view) {
|
|
|
13347
13556
|
paneReloads.delete(key);
|
|
13348
13557
|
fresh.className = view.className;
|
|
13349
13558
|
fresh.style.visibility = '';
|
|
13559
|
+
const hadFocus = view.contains(document.activeElement);
|
|
13350
13560
|
view.remove();
|
|
13351
13561
|
paneFrames.set(key, fresh);
|
|
13562
|
+
const target = hadFocus && paneFocusTarget(fresh);
|
|
13563
|
+
if (target) focusPaneTarget(target);
|
|
13352
13564
|
syncPaneReview();
|
|
13353
13565
|
return true;
|
|
13354
13566
|
}
|
|
@@ -13393,6 +13605,27 @@ function activePaneView() {
|
|
|
13393
13605
|
return wantsTerminal() ? null : paneViews.querySelector('.pane-view.on.pane-focus');
|
|
13394
13606
|
}
|
|
13395
13607
|
|
|
13608
|
+
// A URL page is on another origin and would keep every key from the board, so it gets no focus.
|
|
13609
|
+
function paneFocusTarget(view) {
|
|
13610
|
+
return view?.querySelector(':scope > .pane-doc > .pane-doc-body, :scope > .pane-frame:not(.pane-url-frame)');
|
|
13611
|
+
}
|
|
13612
|
+
|
|
13613
|
+
let paneFocusWanted = null;
|
|
13614
|
+
|
|
13615
|
+
// The key that switches panes also moves focus into the pane, so its content takes the scroll keys.
|
|
13616
|
+
function focusActivePane() {
|
|
13617
|
+
const view = activePaneView();
|
|
13618
|
+
const target = paneFocusTarget(view);
|
|
13619
|
+
paneFocusWanted = view && !view.firstElementChild ? view.dataset.key : null;
|
|
13620
|
+
if (target) focusPaneTarget(target);
|
|
13621
|
+
else if (paneViews.contains(document.activeElement)) document.activeElement.blur();
|
|
13622
|
+
}
|
|
13623
|
+
|
|
13624
|
+
function focusPaneTarget(target) {
|
|
13625
|
+
target.focus({ preventScroll: true });
|
|
13626
|
+
target.contentWindow?.focus();
|
|
13627
|
+
}
|
|
13628
|
+
|
|
13396
13629
|
function isPaneDocOnScreen() {
|
|
13397
13630
|
return !!activePaneView()?.querySelector('.modal-zoomable');
|
|
13398
13631
|
}
|
|
@@ -13918,7 +14151,9 @@ paneStrip.addEventListener('click', (e) => {
|
|
|
13918
14151
|
return;
|
|
13919
14152
|
}
|
|
13920
14153
|
const tab = e.target.closest('.pane-tab');
|
|
13921
|
-
if (tab)
|
|
14154
|
+
if (!tab) return;
|
|
14155
|
+
selectPane(tab.dataset.pane);
|
|
14156
|
+
focusActivePane();
|
|
13922
14157
|
});
|
|
13923
14158
|
paneStrip.addEventListener('auxclick', (e) => {
|
|
13924
14159
|
const tab = e.target.closest('.pane-tab');
|
|
@@ -14288,6 +14523,7 @@ function toggleTerminal() {
|
|
|
14288
14523
|
setTerminalMode(currentSessionId, !terminalModes().has(currentSessionId));
|
|
14289
14524
|
termState.focusNext = true;
|
|
14290
14525
|
syncTerminal();
|
|
14526
|
+
if (!wantsTerminal()) focusActivePane();
|
|
14291
14527
|
}
|
|
14292
14528
|
|
|
14293
14529
|
// The show card sits in the pane but is not the terminal, so Alt+` from it goes to the terminal.
|
|
@@ -14352,7 +14588,7 @@ function setSwapPair(last, previous) {
|
|
|
14352
14588
|
async function swapToPreviousSession() {
|
|
14353
14589
|
const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
|
|
14354
14590
|
if (!target || target === currentSessionId) return;
|
|
14355
|
-
if (terminalPaneFocused()) termState.focusNext = true;
|
|
14591
|
+
if (terminalPaneFocused() || isShowCardFocused()) termState.focusNext = true;
|
|
14356
14592
|
await revealSession(target);
|
|
14357
14593
|
}
|
|
14358
14594
|
|
|
@@ -14451,9 +14687,12 @@ function takeTerminalFocus(sessionId) {
|
|
|
14451
14687
|
}
|
|
14452
14688
|
|
|
14453
14689
|
// The terminal attaches asynchronously; by then the user may be typing elsewhere or reading a modal.
|
|
14690
|
+
// A session on the trust dialog has no transcript yet, so it may not be in `sessions`.
|
|
14454
14691
|
function promptAwaitsUser(sessionId) {
|
|
14455
|
-
if (
|
|
14456
|
-
|
|
14692
|
+
if (isAnyModalOpen() || document.activeElement?.matches(TEXT_FIELD_SELECTOR)) return false;
|
|
14693
|
+
if (trustWaiting.has(sessionId)) return true;
|
|
14694
|
+
const session = sessions.find((s) => s.id === sessionId);
|
|
14695
|
+
return !!session && isWaitingSession(session);
|
|
14457
14696
|
}
|
|
14458
14697
|
|
|
14459
14698
|
function onTerminalShown(focus = true) {
|
|
@@ -14963,6 +15202,7 @@ async function openTerminal(sessionId, mode, attempt = 0) {
|
|
|
14963
15202
|
name: spec.name,
|
|
14964
15203
|
worktree: spec.worktree,
|
|
14965
15204
|
model: spec.model,
|
|
15205
|
+
effort: spec.effort,
|
|
14966
15206
|
prompt: spec.prompt,
|
|
14967
15207
|
edit: spec.edit,
|
|
14968
15208
|
forkOf: spec.forkOf,
|
|
@@ -16210,7 +16450,6 @@ function placeholderSession(id, spec) {
|
|
|
16210
16450
|
mode: spec.mode,
|
|
16211
16451
|
edit: !!spec.edit,
|
|
16212
16452
|
name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
16213
|
-
dispatchGroup: spec.group || undefined,
|
|
16214
16453
|
dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
|
|
16215
16454
|
project: spec.cwd,
|
|
16216
16455
|
worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
|
|
@@ -16229,7 +16468,22 @@ function mergePlaceholders(list) {
|
|
|
16229
16468
|
for (const id of [...specs.keys()]) if (ids.has(id)) specs.delete(id);
|
|
16230
16469
|
}
|
|
16231
16470
|
const pending = [...newSpecs, ...dispatchSpecs].sort((a, b) => b[1].startedAt - a[1].startedAt);
|
|
16232
|
-
|
|
16471
|
+
const placeholders = pending.map(([id, spec]) => placeholderSession(id, spec));
|
|
16472
|
+
revealNewPlaceholders(placeholders);
|
|
16473
|
+
return [...placeholders, ...list];
|
|
16474
|
+
}
|
|
16475
|
+
|
|
16476
|
+
// Ids whose sections were expanded once; a section the user collapses again stays collapsed.
|
|
16477
|
+
const revealedPlaceholderIds = new Set();
|
|
16478
|
+
|
|
16479
|
+
function revealNewPlaceholders(placeholders) {
|
|
16480
|
+
let changed = false;
|
|
16481
|
+
for (const p of placeholders) {
|
|
16482
|
+
if (revealedPlaceholderIds.has(p.id)) continue;
|
|
16483
|
+
revealedPlaceholderIds.add(p.id);
|
|
16484
|
+
if (uncollapseFor(p)) changed = true;
|
|
16485
|
+
}
|
|
16486
|
+
if (changed) persistCollapsedGroups();
|
|
16233
16487
|
}
|
|
16234
16488
|
|
|
16235
16489
|
async function loadDispatches() {
|
|
@@ -16283,10 +16537,14 @@ function renderPlaceholderCard(session, depth) {
|
|
|
16283
16537
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
16284
16538
|
const projectHtml = renderProjectIdentity(session);
|
|
16285
16539
|
return `
|
|
16286
|
-
<button onclick="
|
|
16540
|
+
<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
16541
|
<div class="session-name">${escapeHtml(session.name)}</div>
|
|
16288
16542
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
16289
|
-
|
|
16543
|
+
${
|
|
16544
|
+
trustWaiting.has(session.id)
|
|
16545
|
+
? `<div class="session-waiting"><span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>trust this folder?</div>`
|
|
16546
|
+
: `<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>`
|
|
16547
|
+
}
|
|
16290
16548
|
</button>
|
|
16291
16549
|
`;
|
|
16292
16550
|
}
|
|
@@ -16296,9 +16554,8 @@ async function restorePendingSessions() {
|
|
|
16296
16554
|
document.getElementById('new-session-btn').hidden = !terminalAvailable();
|
|
16297
16555
|
const terminals = terminalAvailable() ? loadTerminals() : null;
|
|
16298
16556
|
await loadDispatches();
|
|
16299
|
-
if (!terminals) return;
|
|
16300
16557
|
try {
|
|
16301
|
-
for (const t of await terminals) {
|
|
16558
|
+
for (const t of terminals ? await terminals : []) {
|
|
16302
16559
|
if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
|
|
16303
16560
|
newSpecs.set(t.id, {
|
|
16304
16561
|
cwd: t.cwd,
|
|
@@ -16311,6 +16568,8 @@ async function restorePendingSessions() {
|
|
|
16311
16568
|
});
|
|
16312
16569
|
}
|
|
16313
16570
|
} catch (_) {}
|
|
16571
|
+
// Sessions that were already running keep the collapse state saved before the reload.
|
|
16572
|
+
for (const id of [...newSpecs.keys(), ...dispatchSpecs.keys()]) revealedPlaceholderIds.add(id);
|
|
16314
16573
|
}
|
|
16315
16574
|
|
|
16316
16575
|
async function loadTerminals() {
|
|
@@ -16319,6 +16578,7 @@ async function loadTerminals() {
|
|
|
16319
16578
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
16320
16579
|
const list = data.sessions || [];
|
|
16321
16580
|
runningTerminals = new Set(list.map((t) => t.id));
|
|
16581
|
+
setTrustWaiting(list.filter((t) => t.awaitingTrust).map((t) => t.id));
|
|
16322
16582
|
rememberTerminalIds(list);
|
|
16323
16583
|
syncShowCard();
|
|
16324
16584
|
pushTerminalClaims();
|
|
@@ -16326,8 +16586,26 @@ async function loadTerminals() {
|
|
|
16326
16586
|
return list;
|
|
16327
16587
|
}
|
|
16328
16588
|
|
|
16329
|
-
|
|
16589
|
+
// A dispatched session never had its terminal toggled on, so the dialog would stay out of sight.
|
|
16590
|
+
function setTrustWaiting(ids) {
|
|
16591
|
+
const before = trustWaiting;
|
|
16592
|
+
trustWaiting = new Set(ids);
|
|
16593
|
+
for (const id of trustWaiting) {
|
|
16594
|
+
if (before.has(id)) continue;
|
|
16595
|
+
setTerminalMode(id, true);
|
|
16596
|
+
if (id !== currentSessionId || viewMode !== 'session') continue;
|
|
16597
|
+
if (termState.shown && termState.attached) {
|
|
16598
|
+
if (promptAwaitsUser(id)) focusTerminalPane();
|
|
16599
|
+
} else {
|
|
16600
|
+
termState.openedByUser = id;
|
|
16601
|
+
syncTerminal();
|
|
16602
|
+
}
|
|
16603
|
+
}
|
|
16604
|
+
}
|
|
16605
|
+
|
|
16606
|
+
function setRunningTerminals(ids, awaitingTrust = []) {
|
|
16330
16607
|
runningTerminals = new Set(ids);
|
|
16608
|
+
setTrustWaiting(awaitingTrust);
|
|
16331
16609
|
forgetEndedTerminals();
|
|
16332
16610
|
syncShowCard();
|
|
16333
16611
|
pushTerminalClaims();
|
|
@@ -16351,10 +16629,12 @@ function openNewSession(folder, resume = false) {
|
|
|
16351
16629
|
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt']) document.getElementById(id).value = '';
|
|
16352
16630
|
document.getElementById('ns-wt').checked = false;
|
|
16353
16631
|
document.getElementById('ns-model').value = '';
|
|
16632
|
+
document.getElementById('ns-effort').value = '';
|
|
16354
16633
|
document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
|
|
16355
16634
|
document.getElementById('ns-edit').checked = store.getItem(NEW_SESSION_EDIT_KEY) === 'true';
|
|
16356
16635
|
setNewSessionError('');
|
|
16357
|
-
const
|
|
16636
|
+
const session = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
16637
|
+
const current = session?.worktree?.repo || session?.project;
|
|
16358
16638
|
setNewSessionFolder(folder || current || '');
|
|
16359
16639
|
document.getElementById('new-session-modal').classList.add('visible');
|
|
16360
16640
|
const input = document.getElementById('ns-folder');
|
|
@@ -16458,6 +16738,7 @@ function newSessionValues() {
|
|
|
16458
16738
|
name: document.getElementById('ns-name').value.trim() || null,
|
|
16459
16739
|
worktree: wtOn ? wtName || true : false,
|
|
16460
16740
|
model: document.getElementById('ns-model').value || null,
|
|
16741
|
+
effort: document.getElementById('ns-effort').value || null,
|
|
16461
16742
|
prompt: document.getElementById('ns-prompt').value.trim() || null,
|
|
16462
16743
|
edit,
|
|
16463
16744
|
stay: !ns.resume && !edit && document.getElementById('ns-stay').checked,
|
|
@@ -16666,7 +16947,7 @@ function initNewSession() {
|
|
|
16666
16947
|
store.setItem(NEW_SESSION_EDIT_KEY, String(e.target.checked));
|
|
16667
16948
|
renderNewSessionForm();
|
|
16668
16949
|
});
|
|
16669
|
-
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
|
|
16950
|
+
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model', 'ns-effort']) {
|
|
16670
16951
|
document.getElementById(id).addEventListener('input', renderNewSessionForm);
|
|
16671
16952
|
}
|
|
16672
16953
|
if (SpeechRecognitionApi) {
|
|
@@ -16793,6 +17074,7 @@ loadModalFullscreen();
|
|
|
16793
17074
|
initModalResize();
|
|
16794
17075
|
loadPanelWidths();
|
|
16795
17076
|
initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width');
|
|
17077
|
+
initDetailLayout();
|
|
16796
17078
|
initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width');
|
|
16797
17079
|
initMessagePanelFull();
|
|
16798
17080
|
|