claude-code-kanban 4.21.0 → 4.22.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/lib/file-url.js +36 -0
- package/package.json +1 -1
- package/public/app.js +980 -127
- package/public/style.css +399 -39
- package/server.js +202 -85
package/public/app.js
CHANGED
|
@@ -16,6 +16,8 @@ let recentProjects = new Set();
|
|
|
16
16
|
let projectsCacheDirty = true;
|
|
17
17
|
const collapsedProjectGroups = new Set();
|
|
18
18
|
let stableGroupOrder = []; // cached project path order to prevent jumping
|
|
19
|
+
let sessionGroups = []; // user-named groups: [{id, name, color, members:[{type,ref}]}]
|
|
20
|
+
let sgDrag = null; // in-flight sidebar drag: {kind:'session'|'project'|'group', ref}
|
|
19
21
|
let searchQuery = ''; // Search query for fuzzy search
|
|
20
22
|
let allTasksCache = []; // Cache all tasks for search
|
|
21
23
|
let ownerFilter = '';
|
|
@@ -33,7 +35,7 @@ let selectedSessionId = null;
|
|
|
33
35
|
// Task stays selected (keyboard nav) but its white highlight is dimmed once the detail panel closes.
|
|
34
36
|
let taskHighlightDimmed = false;
|
|
35
37
|
let focusZone = 'board'; // 'board' | 'sidebar'
|
|
36
|
-
let appConfig = { marketplaceUrl: null, costUrl: null, memoryUrl: null };
|
|
38
|
+
let appConfig = { marketplaceUrl: null, costUrl: null, memoryUrl: null, scratchAvailable: false };
|
|
37
39
|
let selectedSessionIdx = -1;
|
|
38
40
|
let selectedSessionKbId = null;
|
|
39
41
|
let sessionJustSelected = false;
|
|
@@ -1745,7 +1747,7 @@ function _renderPinToDetail(pin) {
|
|
|
1745
1747
|
const pinResultHtml = pinFindings
|
|
1746
1748
|
? ''
|
|
1747
1749
|
: renderToolResultHtml(pin.toolResult, pin.toolResultTruncated, pin.toolResultFull, pin.toolUseId);
|
|
1748
|
-
const pinDetailEscaped = escapeHtml(fullText);
|
|
1750
|
+
const pinDetailEscaped = escapeHtml(stripAnsi(fullText));
|
|
1749
1751
|
const pinDetailRendered = pin.tool === 'Bash' ? highlightBash(pinDetailEscaped) : pinDetailEscaped;
|
|
1750
1752
|
// Tool-result images are intentionally omitted here: their URL is built from
|
|
1751
1753
|
// the global currentSessionId, but a pin can belong to a different session,
|
|
@@ -1817,7 +1819,7 @@ function renderUserAttachments(m) {
|
|
|
1817
1819
|
.map((ref) => {
|
|
1818
1820
|
const safeId = escapeHtml(ref.toolUseId);
|
|
1819
1821
|
const shortId = ref.toolUseId.length > 14 ? `${ref.toolUseId.slice(0, 14)}…` : ref.toolUseId;
|
|
1820
|
-
const preview = ref.preview ?
|
|
1822
|
+
const preview = ref.preview ? sanitizeOutputHtml(ref.preview) : '<em>(no text)</em>';
|
|
1821
1823
|
const expandId = `user-tr-${ref.toolUseId}`;
|
|
1822
1824
|
return `<details class="user-attach-toolresult">
|
|
1823
1825
|
<summary>Tool result <code>${escapeHtml(shortId)}</code></summary>
|
|
@@ -1895,7 +1897,7 @@ function showMsgDetail(idx) {
|
|
|
1895
1897
|
} else if (taskResultHtml || findingsHtml) {
|
|
1896
1898
|
mainHtml = descHtml || '';
|
|
1897
1899
|
} else if (fullText) {
|
|
1898
|
-
const detailEscaped = escapeHtml(fullText);
|
|
1900
|
+
const detailEscaped = escapeHtml(stripAnsi(fullText));
|
|
1899
1901
|
const detailRendered = m.tool === 'Bash' ? highlightBash(detailEscaped) : detailEscaped;
|
|
1900
1902
|
mainHtml = `${descHtml}<pre class="${TINTED_PRE_CLASS}">${detailRendered}</pre>`;
|
|
1901
1903
|
} else {
|
|
@@ -2238,7 +2240,7 @@ function getToolDetail(tool, params, detail) {
|
|
|
2238
2240
|
}
|
|
2239
2241
|
function renderTaskResult(toolResult) {
|
|
2240
2242
|
if (!toolResult) return '';
|
|
2241
|
-
const lines = toolResult.trim().split('\n');
|
|
2243
|
+
const lines = sanitizeOutput(toolResult).trim().split('\n');
|
|
2242
2244
|
const fields = [];
|
|
2243
2245
|
for (const line of lines) {
|
|
2244
2246
|
const m = line.match(/^([A-Za-z #]+):\s*(.+)$/);
|
|
@@ -2347,7 +2349,7 @@ function renderFindingsReport(params, toolResult) {
|
|
|
2347
2349
|
});
|
|
2348
2350
|
html += '</div>';
|
|
2349
2351
|
}
|
|
2350
|
-
if (toolResult) html += `<div class="findings-result-note">${
|
|
2352
|
+
if (toolResult) html += `<div class="findings-result-note">${sanitizeOutputHtml(toolResult.trim())}</div>`;
|
|
2351
2353
|
return `${html}</div>`;
|
|
2352
2354
|
}
|
|
2353
2355
|
//#endregion
|
|
@@ -2464,6 +2466,16 @@ function stripLineNumbers(text) {
|
|
|
2464
2466
|
return text.replace(/^ *\d+[→\t]/gm, '');
|
|
2465
2467
|
}
|
|
2466
2468
|
|
|
2469
|
+
// The two entry points for raw tool output: plain text (clipboard, field
|
|
2470
|
+
// parsing) and HTML (any pane that renders it, where ANSI becomes colour).
|
|
2471
|
+
function sanitizeOutput(text) {
|
|
2472
|
+
return typeof text === 'string' ? stripAnsi(stripLineNumbers(text)) : text;
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
function sanitizeOutputHtml(text) {
|
|
2476
|
+
return typeof text === 'string' ? ansiToHtml(stripLineNumbers(text)) : '';
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2467
2479
|
function highlightBash(escaped) {
|
|
2468
2480
|
return escaped
|
|
2469
2481
|
.replace(/^(\s*)(#.*)$/gm, '$1<span style="color:#6a9955">$2</span>')
|
|
@@ -2520,19 +2532,20 @@ function autoSizeModal(modal, body) {
|
|
|
2520
2532
|
}
|
|
2521
2533
|
const hasTable = body.querySelector('table') !== null;
|
|
2522
2534
|
const hasPre = body.querySelector('pre') !== null;
|
|
2523
|
-
|
|
2535
|
+
// hasPre is already computed and short-circuits the textContent walk, which is
|
|
2536
|
+
// no longer cheap now that colourised output puts thousands of spans in there.
|
|
2537
|
+
const desired = hasTable ? 1100 : hasPre || body.textContent.length > 2000 ? 960 : 860;
|
|
2524
2538
|
const current = parseFloat(getComputedStyle(modal).maxWidth) || 0;
|
|
2525
2539
|
if (desired > current) modal.style.maxWidth = `${desired}px`;
|
|
2526
2540
|
}
|
|
2527
2541
|
|
|
2528
2542
|
function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
|
|
2529
2543
|
if (!toolResult) return '';
|
|
2530
|
-
const
|
|
2531
|
-
const escaped = escapeHtml(stripped);
|
|
2544
|
+
const escaped = sanitizeOutputHtml(toolResult);
|
|
2532
2545
|
let truncLabel = '',
|
|
2533
2546
|
fullBlock = '';
|
|
2534
2547
|
if (isTruncated && fullResult) {
|
|
2535
|
-
const toggle = makeExpandToggle(escaped,
|
|
2548
|
+
const toggle = makeExpandToggle(escaped, sanitizeOutputHtml(fullResult));
|
|
2536
2549
|
truncLabel = toggle.btn;
|
|
2537
2550
|
fullBlock = toggle.full;
|
|
2538
2551
|
} else if (isTruncated && toolUseId) {
|
|
@@ -2574,7 +2587,7 @@ async function _toggleToolResultExpand(btn) {
|
|
|
2574
2587
|
);
|
|
2575
2588
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
2576
2589
|
const { content } = await r.json();
|
|
2577
|
-
f.
|
|
2590
|
+
f.innerHTML = sanitizeOutputHtml(content);
|
|
2578
2591
|
btn.dataset.loaded = '1';
|
|
2579
2592
|
} catch (_e) {
|
|
2580
2593
|
btn.textContent = 'Show more';
|
|
@@ -2589,7 +2602,7 @@ async function _toggleToolResultExpand(btn) {
|
|
|
2589
2602
|
|
|
2590
2603
|
function buildToolContent(m) {
|
|
2591
2604
|
let content = m.fullDetail || m.detail || '';
|
|
2592
|
-
if (m.toolResult) content += `\n\n--- Output ---\n\n${m.toolResultFull || m.toolResult}`;
|
|
2605
|
+
if (m.toolResult) content += `\n\n--- Output ---\n\n${sanitizeOutput(m.toolResultFull || m.toolResult)}`;
|
|
2593
2606
|
return content;
|
|
2594
2607
|
}
|
|
2595
2608
|
|
|
@@ -3002,6 +3015,9 @@ function renderAllTasks() {
|
|
|
3002
3015
|
}
|
|
3003
3016
|
|
|
3004
3017
|
function renderSessions() {
|
|
3018
|
+
// Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
|
|
3019
|
+
// half-typed group name, and the SSE path can fire at any moment — defer instead.
|
|
3020
|
+
if (sgDrag || sgIsEditing()) return;
|
|
3005
3021
|
// Update project dropdown
|
|
3006
3022
|
updateProjectDropdown();
|
|
3007
3023
|
|
|
@@ -3054,12 +3070,14 @@ function renderSessions() {
|
|
|
3054
3070
|
)
|
|
3055
3071
|
taskMatchIds.add(t.sessionId);
|
|
3056
3072
|
}
|
|
3073
|
+
const groupMatchIds = sgSearchMatchIds(searchQuery);
|
|
3057
3074
|
const matchesSearch = (s) =>
|
|
3058
3075
|
(s.name && fuzzyMatch(s.name, searchQuery)) ||
|
|
3059
3076
|
(s.id && fuzzyMatch(s.id, searchQuery)) ||
|
|
3060
3077
|
(s.project && fuzzyMatch(s.project, searchQuery)) ||
|
|
3061
3078
|
(s.description && fuzzyMatch(s.description, searchQuery)) ||
|
|
3062
|
-
taskMatchIds.has(s.id)
|
|
3079
|
+
taskMatchIds.has(s.id) ||
|
|
3080
|
+
groupMatchIds.has(s.id);
|
|
3063
3081
|
|
|
3064
3082
|
filteredSessions = filteredSessions.filter(matchesSearch);
|
|
3065
3083
|
|
|
@@ -3152,7 +3170,7 @@ function renderSessions() {
|
|
|
3152
3170
|
const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
|
|
3153
3171
|
const sid = escAttrJs(session.id);
|
|
3154
3172
|
return `
|
|
3155
|
-
<button onclick="fetchTasks('${sid}')" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
|
|
3173
|
+
<button onclick="fetchTasks('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
|
|
3156
3174
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
3157
3175
|
<div class="session-name">${escapeHtml(primaryName)}</div>
|
|
3158
3176
|
${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
|
|
@@ -3184,13 +3202,162 @@ function renderSessions() {
|
|
|
3184
3202
|
`;
|
|
3185
3203
|
};
|
|
3186
3204
|
|
|
3205
|
+
const groupPinned = localStorage.getItem('groupPinnedSessions') !== 'false';
|
|
3206
|
+
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isPlacedPinned(s.id) && !isSessionActive(s) ? 1 : 0);
|
|
3207
|
+
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
3208
|
+
const countHtml = (arr) => {
|
|
3209
|
+
const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
3210
|
+
return `<span class="group-count" title="${active} active / ${arr.length} total">${active > 0 ? `<span class="group-count-active">${active}</span><span class="group-count-sep">/</span>` : ''}${arr.length}</span>`;
|
|
3211
|
+
};
|
|
3212
|
+
const renderGroupSessions = (sessions, pinKey) => {
|
|
3213
|
+
if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
|
|
3214
|
+
const gPinned = sessions.filter((s) => isPlacedPinned(s.id) && !isPlacedSticky(s.id));
|
|
3215
|
+
if (gPinned.length === 0) return sessions.map(renderSessionCard).join('');
|
|
3216
|
+
const gIdlePinned = gPinned.filter((s) => !isSessionActive(s));
|
|
3217
|
+
const gUnpinned = sessions.filter((s) => !isPlacedPinned(s.id) || isSessionActive(s) || isPlacedSticky(s.id));
|
|
3218
|
+
const pinCollapsed = collapsedProjectGroups.has(pinKey);
|
|
3219
|
+
if (gIdlePinned.length === 0 && !pinCollapsed) return gUnpinned.map(renderSessionCard).join('');
|
|
3220
|
+
return (
|
|
3221
|
+
'<div class="pinned-sub-section">' +
|
|
3222
|
+
'<div class="pinned-sub-header' +
|
|
3223
|
+
(pinCollapsed ? ' collapsed' : '') +
|
|
3224
|
+
'" data-group-path="' +
|
|
3225
|
+
escapeHtml(pinKey) +
|
|
3226
|
+
'">' +
|
|
3227
|
+
groupChevronSvg(10) +
|
|
3228
|
+
'<span class="pinned-sub-label">Pinned</span>' +
|
|
3229
|
+
'<span class="group-count">' +
|
|
3230
|
+
gIdlePinned.length +
|
|
3231
|
+
'</span>' +
|
|
3232
|
+
'<span class="pinned-ungroup-btn" title="Ungroup pinned sessions">×</span>' +
|
|
3233
|
+
'</div>' +
|
|
3234
|
+
'<div class="pinned-sub-items' +
|
|
3235
|
+
(pinCollapsed ? ' collapsed' : '') +
|
|
3236
|
+
'">' +
|
|
3237
|
+
gIdlePinned.map(renderSessionCard).join('') +
|
|
3238
|
+
'</div>' +
|
|
3239
|
+
'</div>' +
|
|
3240
|
+
gUnpinned.map(renderSessionCard).join('')
|
|
3241
|
+
);
|
|
3242
|
+
};
|
|
3243
|
+
|
|
3244
|
+
// Renders one project block. `nested` = it sits inside a named group, so it is indented and
|
|
3245
|
+
// its own drag payload still resolves to the project path.
|
|
3246
|
+
const projectBlock = (projectPath, projectSessions, nested) => {
|
|
3247
|
+
const folderName = projectPath.split(/[/\\]/).pop();
|
|
3248
|
+
const isCollapsed = collapsedProjectGroups.has(projectPath);
|
|
3249
|
+
const escapedPath = escapeHtml(projectPath);
|
|
3250
|
+
const nestedCls = nested ? ' sg-nested' : '';
|
|
3251
|
+
const breadcrumbParts = projectPath
|
|
3252
|
+
.replace(/^\/home\/[^/]+/, '~')
|
|
3253
|
+
.split(/[/\\]/)
|
|
3254
|
+
.filter(Boolean);
|
|
3255
|
+
const breadcrumbHtml = breadcrumbParts
|
|
3256
|
+
.map((p, i) => (i < breadcrumbParts.length - 1 ? `${escapeHtml(p)}<span class="sep">/</span>` : escapeHtml(p)))
|
|
3257
|
+
.join('');
|
|
3258
|
+
|
|
3259
|
+
return `
|
|
3260
|
+
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}${nestedCls}" draggable="true" data-group-path="${escapedPath}" data-project-path="${escapedPath}">
|
|
3261
|
+
${groupChevronSvg()}
|
|
3262
|
+
<span class="group-name">${escapeHtml(folderName)}</span>
|
|
3263
|
+
${countHtml(projectSessions)}
|
|
3264
|
+
<span class="project-view-btn" data-project-path="${escapedPath}" title="Open project view — combined tasks from all sessions">
|
|
3265
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
|
3266
|
+
</span>
|
|
3267
|
+
</div>
|
|
3268
|
+
<div class="project-group-breadcrumb${nestedCls}" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
|
|
3269
|
+
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}${nestedCls}" data-project-path="${escapedPath}">
|
|
3270
|
+
${renderGroupSessions(projectSessions, `__pinned_${projectPath}__`)}
|
|
3271
|
+
</div>
|
|
3272
|
+
`;
|
|
3273
|
+
};
|
|
3274
|
+
|
|
3275
|
+
// Named groups are a pure partition of the already-filtered list: whatever they don't claim
|
|
3276
|
+
// falls through to the project blocks below, which look exactly as they did before groups.
|
|
3277
|
+
const sgBuckets = new Map();
|
|
3278
|
+
let sgRest = filteredSessions;
|
|
3279
|
+
if (sessionGroups.length > 0) {
|
|
3280
|
+
sgRest = [];
|
|
3281
|
+
for (const session of filteredSessions) {
|
|
3282
|
+
const group = sgGroupForSession(session);
|
|
3283
|
+
if (group) {
|
|
3284
|
+
if (!sgBuckets.has(group.id)) sgBuckets.set(group.id, []);
|
|
3285
|
+
sgBuckets.get(group.id).push(session);
|
|
3286
|
+
} else {
|
|
3287
|
+
sgRest.push(session);
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
// A group emptied by a filter hides its header; a group the user just created (no filters)
|
|
3292
|
+
// shows its drop-here state instead of vanishing.
|
|
3293
|
+
const sgFiltering = !!searchQuery || activityFilter.size > 0 || (!!filterProject && filterProject !== '__recent__');
|
|
3294
|
+
|
|
3295
|
+
// flat = the "All sessions" view, which has no project sub-blocks.
|
|
3296
|
+
const sgSectionHtml = (flat) => {
|
|
3297
|
+
if (sessionGroups.length === 0) return '';
|
|
3298
|
+
let out = '<div class="sg-section-label">Groups</div>';
|
|
3299
|
+
for (const group of sessionGroups) {
|
|
3300
|
+
const bucket = sgBuckets.get(group.id) || [];
|
|
3301
|
+
if (bucket.length === 0 && sgFiltering) continue;
|
|
3302
|
+
if (!groupPinned) bucket.sort(pinSort);
|
|
3303
|
+
const collapsed = collapsedProjectGroups.has(sgKey(group.id));
|
|
3304
|
+
let body = '';
|
|
3305
|
+
if (flat) {
|
|
3306
|
+
body = bucket.map(renderSessionCard).join('');
|
|
3307
|
+
} else {
|
|
3308
|
+
const loose = [];
|
|
3309
|
+
const byProject = new Map();
|
|
3310
|
+
for (const s of bucket) {
|
|
3311
|
+
const host = sgHostOf(group, s);
|
|
3312
|
+
if (host) {
|
|
3313
|
+
if (!byProject.has(host)) byProject.set(host, []);
|
|
3314
|
+
byProject.get(host).push(s);
|
|
3315
|
+
} else {
|
|
3316
|
+
loose.push(s);
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
// Project members first (in the user's member order), then the individually placed
|
|
3320
|
+
// sessions — one Pinned sub-section per group instead of one per interleaved run.
|
|
3321
|
+
for (const m of group.members) {
|
|
3322
|
+
if (m.type !== 'project') continue;
|
|
3323
|
+
const arr = byProject.get(m.ref);
|
|
3324
|
+
// A member project with nothing left in it still renders: it shows the membership, and
|
|
3325
|
+
// it is the drop target for putting a session back under it.
|
|
3326
|
+
if (arr?.length || !sgFiltering) body += projectBlock(m.ref, arr || [], true);
|
|
3327
|
+
}
|
|
3328
|
+
if (loose.length) {
|
|
3329
|
+
// Individually placed sessions follow the order the user dragged them into.
|
|
3330
|
+
const slot = new Map(group.members.map((m, i) => [`${m.type}:${m.ref}`, i]));
|
|
3331
|
+
loose.sort((a, b) => (slot.get(`session:${a.id}`) ?? 0) - (slot.get(`session:${b.id}`) ?? 0));
|
|
3332
|
+
// Label them, else a card sitting under the project blocks reads as stranded.
|
|
3333
|
+
if (body) body += '<div class="sg-sub-label">Sessions</div>';
|
|
3334
|
+
body += renderGroupSessions(loose, `__pinned_group_${group.id}__`);
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
if (!body) body = '<div class="sg-empty">Drop a session or a project here</div>';
|
|
3338
|
+
out += sgHeaderHtml(group, countHtml(bucket));
|
|
3339
|
+
out += `<div class="session-group-sessions${collapsed ? ' collapsed' : ''}" data-group-id="${escapeHtml(group.id)}">${body}</div>`;
|
|
3340
|
+
}
|
|
3341
|
+
return out;
|
|
3342
|
+
};
|
|
3343
|
+
|
|
3344
|
+
let html = '';
|
|
3345
|
+
if (!groupPinned && pinnedSessionIds.size > 0 && filteredSessions.some((s) => pinnedSessionIds.has(s.id))) {
|
|
3346
|
+
html += `<div class="pinned-regroup-banner">
|
|
3347
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="3"/><path d="M5 10l7-7 7 7"/><line x1="4" y1="21" x2="20" y2="21"/></svg>
|
|
3348
|
+
Group pinned sessions
|
|
3349
|
+
</div>`;
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3187
3352
|
// Group active sessions by project
|
|
3188
3353
|
if (sessionFilter === 'active') {
|
|
3189
3354
|
// Auto-expand a collapsed section when newly-active work lands in it (live refresh).
|
|
3190
3355
|
expandActiveGroups({ onlyNew: true });
|
|
3356
|
+
html += sgSectionHtml(false);
|
|
3357
|
+
|
|
3191
3358
|
const groups = new Map();
|
|
3192
3359
|
const ungrouped = [];
|
|
3193
|
-
for (const session of
|
|
3360
|
+
for (const session of sgRest) {
|
|
3194
3361
|
if (session.project) {
|
|
3195
3362
|
if (!groups.has(session.project)) groups.set(session.project, []);
|
|
3196
3363
|
groups.get(session.project).push(session);
|
|
@@ -3198,106 +3365,42 @@ function renderSessions() {
|
|
|
3198
3365
|
ungrouped.push(session);
|
|
3199
3366
|
}
|
|
3200
3367
|
}
|
|
3201
|
-
const groupPinned = localStorage.getItem('groupPinnedSessions') !== 'false';
|
|
3202
|
-
const renderGroupSessions = (sessions, pinKey) => {
|
|
3203
|
-
if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
|
|
3204
|
-
const gPinned = sessions.filter((s) => isPlacedPinned(s.id) && !isPlacedSticky(s.id));
|
|
3205
|
-
if (gPinned.length === 0) return sessions.map(renderSessionCard).join('');
|
|
3206
|
-
const gIdlePinned = gPinned.filter((s) => !isSessionActive(s));
|
|
3207
|
-
const gUnpinned = sessions.filter((s) => !isPlacedPinned(s.id) || isSessionActive(s) || isPlacedSticky(s.id));
|
|
3208
|
-
const pinCollapsed = collapsedProjectGroups.has(pinKey);
|
|
3209
|
-
if (gIdlePinned.length === 0 && !pinCollapsed) return gUnpinned.map(renderSessionCard).join('');
|
|
3210
|
-
return (
|
|
3211
|
-
'<div class="pinned-sub-section">' +
|
|
3212
|
-
'<div class="pinned-sub-header' +
|
|
3213
|
-
(pinCollapsed ? ' collapsed' : '') +
|
|
3214
|
-
'" data-group-path="' +
|
|
3215
|
-
escapeHtml(pinKey) +
|
|
3216
|
-
'">' +
|
|
3217
|
-
'<svg class="group-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>' +
|
|
3218
|
-
'<span class="pinned-sub-label">Pinned</span>' +
|
|
3219
|
-
'<span class="group-count">' +
|
|
3220
|
-
gIdlePinned.length +
|
|
3221
|
-
'</span>' +
|
|
3222
|
-
'<span class="pinned-ungroup-btn" title="Ungroup pinned sessions">×</span>' +
|
|
3223
|
-
'</div>' +
|
|
3224
|
-
'<div class="pinned-sub-items' +
|
|
3225
|
-
(pinCollapsed ? ' collapsed' : '') +
|
|
3226
|
-
'">' +
|
|
3227
|
-
gIdlePinned.map(renderSessionCard).join('') +
|
|
3228
|
-
'</div>' +
|
|
3229
|
-
'</div>' +
|
|
3230
|
-
gUnpinned.map(renderSessionCard).join('')
|
|
3231
|
-
);
|
|
3232
|
-
};
|
|
3233
3368
|
if (!groupPinned && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
|
|
3234
|
-
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isPlacedPinned(s.id) && !isSessionActive(s) ? 1 : 0);
|
|
3235
|
-
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
3236
3369
|
for (const [, arr] of groups) arr.sort(pinSort);
|
|
3237
3370
|
ungrouped.sort(pinSort);
|
|
3238
3371
|
}
|
|
3239
3372
|
|
|
3240
|
-
// Stable group order: preserve existing order, append new groups sorted by recency
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
const
|
|
3244
|
-
const
|
|
3245
|
-
|
|
3246
|
-
const
|
|
3247
|
-
|
|
3248
|
-
);
|
|
3249
|
-
newPaths.sort((a, b) => maxTime.get(b) - maxTime.get(a));
|
|
3373
|
+
// Stable group order: preserve existing order, append new groups sorted by recency.
|
|
3374
|
+
// Grouped projects stay in the array (they are just not rendered here), so dragging one
|
|
3375
|
+
// back out restores its old slot instead of jumping to the top.
|
|
3376
|
+
const latestByPath = new Map();
|
|
3377
|
+
for (const s of filteredSessions) {
|
|
3378
|
+
if (!s.project) continue;
|
|
3379
|
+
const t = new Date(s.modifiedAt).getTime();
|
|
3380
|
+
latestByPath.set(s.project, Math.max(latestByPath.get(s.project) ?? -Infinity, t));
|
|
3250
3381
|
}
|
|
3382
|
+
const knownPaths = new Set(stableGroupOrder);
|
|
3383
|
+
const keptOrder = stableGroupOrder.filter((p) => latestByPath.has(p));
|
|
3384
|
+
const newPaths = [...latestByPath.keys()].filter((p) => !knownPaths.has(p));
|
|
3385
|
+
if (newPaths.length > 1) newPaths.sort((a, b) => latestByPath.get(b) - latestByPath.get(a));
|
|
3251
3386
|
stableGroupOrder = [...keptOrder, ...newPaths];
|
|
3252
|
-
const sortedGroups = stableGroupOrder.map((p) => [p, groups.get(p)]);
|
|
3387
|
+
const sortedGroups = stableGroupOrder.filter((p) => groups.has(p)).map((p) => [p, groups.get(p)]);
|
|
3253
3388
|
|
|
3254
|
-
|
|
3255
|
-
if (
|
|
3256
|
-
|
|
3257
|
-
if (hasPinnedInView) {
|
|
3258
|
-
html += `<div class="pinned-regroup-banner">
|
|
3259
|
-
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="3"/><path d="M5 10l7-7 7 7"/><line x1="4" y1="21" x2="20" y2="21"/></svg>
|
|
3260
|
-
Group pinned sessions
|
|
3261
|
-
</div>`;
|
|
3262
|
-
}
|
|
3389
|
+
// Rendered even when nothing is left under it: it is the drop target for ungrouping.
|
|
3390
|
+
if (sessionGroups.length > 0) {
|
|
3391
|
+
html += '<div class="sg-section-label sg-ungroup-zone" title="Drop here to remove from a group">Projects</div>';
|
|
3263
3392
|
}
|
|
3264
3393
|
for (const [projectPath, projectSessions] of sortedGroups) {
|
|
3265
|
-
|
|
3266
|
-
const isCollapsed = collapsedProjectGroups.has(projectPath);
|
|
3267
|
-
const escapedPath = escapeHtml(projectPath);
|
|
3268
|
-
const activeCount = projectSessions.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
3269
|
-
const breadcrumbParts = projectPath
|
|
3270
|
-
.replace(/^\/home\/[^/]+/, '~')
|
|
3271
|
-
.split(/[/\\]/)
|
|
3272
|
-
.filter(Boolean);
|
|
3273
|
-
const breadcrumbHtml = breadcrumbParts
|
|
3274
|
-
.map((p, i) => (i < breadcrumbParts.length - 1 ? `${escapeHtml(p)}<span class="sep">/</span>` : escapeHtml(p)))
|
|
3275
|
-
.join('');
|
|
3276
|
-
|
|
3277
|
-
html += `
|
|
3278
|
-
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="${escapedPath}">
|
|
3279
|
-
<svg class="group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
|
3280
|
-
<span class="group-name">${escapeHtml(folderName)}</span>
|
|
3281
|
-
<span class="group-count" title="${activeCount} active / ${projectSessions.length} total">${activeCount > 0 ? `<span class="group-count-active">${activeCount}</span><span class="group-count-sep">/</span>` : ''}${projectSessions.length}</span>
|
|
3282
|
-
<span class="project-view-btn" data-project-path="${escapedPath}" title="Open project view — combined tasks from all sessions">
|
|
3283
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
|
3284
|
-
</span>
|
|
3285
|
-
</div>
|
|
3286
|
-
<div class="project-group-breadcrumb" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
|
|
3287
|
-
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
|
|
3288
|
-
${renderGroupSessions(projectSessions, `__pinned_${projectPath}__`)}
|
|
3289
|
-
</div>
|
|
3290
|
-
`;
|
|
3394
|
+
html += projectBlock(projectPath, projectSessions, false);
|
|
3291
3395
|
}
|
|
3292
3396
|
|
|
3293
3397
|
if (ungrouped.length > 0 && sortedGroups.length > 0) {
|
|
3294
3398
|
const isCollapsed = collapsedProjectGroups.has('__ungrouped__');
|
|
3295
|
-
const ungroupedActive = ungrouped.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
3296
3399
|
html += `
|
|
3297
3400
|
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__ungrouped__">
|
|
3298
|
-
|
|
3401
|
+
${groupChevronSvg()}
|
|
3299
3402
|
<span class="group-name">Ungrouped</span>
|
|
3300
|
-
|
|
3403
|
+
${countHtml(ungrouped)}
|
|
3301
3404
|
</div>
|
|
3302
3405
|
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
|
|
3303
3406
|
${renderGroupSessions(ungrouped, '__pinned___ungrouped__')}
|
|
@@ -3309,7 +3412,11 @@ function renderSessions() {
|
|
|
3309
3412
|
|
|
3310
3413
|
sessionsList.innerHTML = html;
|
|
3311
3414
|
} else {
|
|
3312
|
-
|
|
3415
|
+
const ungroupZone =
|
|
3416
|
+
sessionGroups.length > 0
|
|
3417
|
+
? '<div class="sg-section-label sg-ungroup-zone" title="Drop here to remove from a group">Sessions</div>'
|
|
3418
|
+
: '';
|
|
3419
|
+
sessionsList.innerHTML = html + sgSectionHtml(true) + ungroupZone + sgRest.map(renderSessionCard).join('');
|
|
3313
3420
|
}
|
|
3314
3421
|
|
|
3315
3422
|
const navItems = getNavigableItems();
|
|
@@ -3677,6 +3784,9 @@ function onCardDragEnd(e) {
|
|
|
3677
3784
|
|
|
3678
3785
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3679
3786
|
function onColumnDragOver(e) {
|
|
3787
|
+
// A sidebar session/project drag has nothing to do with task status — don't offer the board
|
|
3788
|
+
// as a drop target for it.
|
|
3789
|
+
if (sgDrag) return;
|
|
3680
3790
|
e.preventDefault();
|
|
3681
3791
|
e.dataTransfer.dropEffect = 'move';
|
|
3682
3792
|
e.currentTarget.classList.add('drag-over');
|
|
@@ -3691,6 +3801,7 @@ function onColumnDragLeave(e) {
|
|
|
3691
3801
|
|
|
3692
3802
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3693
3803
|
async function onColumnDrop(e) {
|
|
3804
|
+
if (sgDrag) return;
|
|
3694
3805
|
e.preventDefault();
|
|
3695
3806
|
e.currentTarget.classList.remove('drag-over');
|
|
3696
3807
|
const newStatus = e.currentTarget.dataset.status;
|
|
@@ -3720,6 +3831,513 @@ async function onColumnDrop(e) {
|
|
|
3720
3831
|
|
|
3721
3832
|
//#endregion
|
|
3722
3833
|
|
|
3834
|
+
//#region SESSION_GROUPS
|
|
3835
|
+
// User-named groups that hold whole projects and individual sessions, dragged in from the
|
|
3836
|
+
// sidebar. They render above the project groups. Membership is a workspace preference kept in
|
|
3837
|
+
// localStorage; an id missing from `sessions` is NOT proof the session is gone (sessionLimit
|
|
3838
|
+
// windows the list), so membership is never garbage-collected on load.
|
|
3839
|
+
const SESSION_GROUPS_KEY = 'sessionGroups';
|
|
3840
|
+
const SG_ACTION_SELECTOR =
|
|
3841
|
+
'.session-pin-btn, .team-info-btn, .plan-indicator, .scratchpad-badge, .bookmarks-badge, .linked-docs-badge, .project-view-btn, .group-path-toggle, .pinned-ungroup-btn, .sg-action';
|
|
3842
|
+
|
|
3843
|
+
// Collapse state shares the existing `collapsedGroups` key; this namespace keeps it from
|
|
3844
|
+
// colliding with a project path or the __ungrouped__ / __pinned_* sentinels.
|
|
3845
|
+
function groupChevronSvg(size = 12) {
|
|
3846
|
+
return `<svg class="group-chevron" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>`;
|
|
3847
|
+
}
|
|
3848
|
+
|
|
3849
|
+
function sgKey(id) {
|
|
3850
|
+
return `__group_${id}__`;
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
function loadSessionGroups() {
|
|
3854
|
+
try {
|
|
3855
|
+
const list = JSON.parse(localStorage.getItem(SESSION_GROUPS_KEY) || 'null')?.groups;
|
|
3856
|
+
sessionGroups = (Array.isArray(list) ? list : [])
|
|
3857
|
+
.filter((g) => g && typeof g.id === 'string')
|
|
3858
|
+
.map((g) => ({
|
|
3859
|
+
id: g.id,
|
|
3860
|
+
name: typeof g.name === 'string' && g.name.trim() ? g.name : 'Group',
|
|
3861
|
+
members: (Array.isArray(g.members) ? g.members : [])
|
|
3862
|
+
.filter((m) => m && (m.type === 'project' || m.type === 'session') && typeof m.ref === 'string')
|
|
3863
|
+
.map((m) => {
|
|
3864
|
+
const out = { type: m.type, ref: m.ref };
|
|
3865
|
+
if (typeof m.under === 'string' && m.under) out.under = m.under;
|
|
3866
|
+
if (m.loose === true) out.loose = true;
|
|
3867
|
+
return out;
|
|
3868
|
+
}),
|
|
3869
|
+
}));
|
|
3870
|
+
} catch (_) {
|
|
3871
|
+
sessionGroups = [];
|
|
3872
|
+
}
|
|
3873
|
+
}
|
|
3874
|
+
|
|
3875
|
+
function persistSessionGroups() {
|
|
3876
|
+
try {
|
|
3877
|
+
localStorage.setItem(SESSION_GROUPS_KEY, JSON.stringify({ version: 1, groups: sessionGroups }));
|
|
3878
|
+
} catch (_) {}
|
|
3879
|
+
}
|
|
3880
|
+
|
|
3881
|
+
function sgGroupById(id) {
|
|
3882
|
+
return sessionGroups.find((g) => g.id === id) || null;
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
function sgGroupOf(type, ref) {
|
|
3886
|
+
return sessionGroups.find((g) => g.members.some((m) => m.type === type && m.ref === ref)) || null;
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
// An individually placed session wins over the placement of its project.
|
|
3890
|
+
function sgGroupForSession(session) {
|
|
3891
|
+
return sgGroupOf('session', session.id) || (session.project ? sgGroupOf('project', session.project) : null);
|
|
3892
|
+
}
|
|
3893
|
+
|
|
3894
|
+
function sgDetach(type, ref) {
|
|
3895
|
+
for (const g of sessionGroups) {
|
|
3896
|
+
g.members = g.members.filter((m) => !(m.type === type && m.ref === ref));
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
// Where a session renders inside a group: the project block it was moved under, its own
|
|
3901
|
+
// project's block, or - when the user lifted it out - the group's own loose list.
|
|
3902
|
+
function sgHostOf(group, session) {
|
|
3903
|
+
if (!group || !session) return null;
|
|
3904
|
+
const m = group.members.find((x) => x.type === 'session' && x.ref === session.id);
|
|
3905
|
+
const isMemberProject = (path) => !!path && group.members.some((x) => x.type === 'project' && x.ref === path);
|
|
3906
|
+
if (m?.under && isMemberProject(m.under)) return m.under;
|
|
3907
|
+
if (!m?.loose && isMemberProject(session.project)) return session.project;
|
|
3908
|
+
return null;
|
|
3909
|
+
}
|
|
3910
|
+
// `opts.under` places the session under another project block of the group; `opts.loose` lifts it
|
|
3911
|
+
// out of its project block onto the group's own level.
|
|
3912
|
+
function sgAssign(groupId, type, ref, opts = {}) {
|
|
3913
|
+
const group = sgGroupById(groupId);
|
|
3914
|
+
if (!group) return;
|
|
3915
|
+
sgDetach(type, ref);
|
|
3916
|
+
// Pulling a whole project in supersedes the individual placements of its sessions.
|
|
3917
|
+
if (type === 'project') {
|
|
3918
|
+
for (const s of sessions) if (s.project === ref) sgDetach('session', s.id);
|
|
3919
|
+
}
|
|
3920
|
+
const own = type === 'session' ? sessions.find((s) => s.id === ref)?.project : null;
|
|
3921
|
+
// Under its own project block is simply the natural placement - no override needed.
|
|
3922
|
+
const under = opts.under && opts.under !== own ? opts.under : null;
|
|
3923
|
+
const loose = !under && !!opts.loose;
|
|
3924
|
+
const covered =
|
|
3925
|
+
type === 'session' && !under && !loose && group.members.some((m) => m.type === 'project' && m.ref === own);
|
|
3926
|
+
// The group already holds the session's project — a separate placement would only strand
|
|
3927
|
+
// the card at the bottom of the group, away from its project block.
|
|
3928
|
+
if (!covered) {
|
|
3929
|
+
const member = { type, ref };
|
|
3930
|
+
if (under) member.under = under;
|
|
3931
|
+
if (loose) member.loose = true;
|
|
3932
|
+
group.members.push(member);
|
|
3933
|
+
}
|
|
3934
|
+
if (collapsedProjectGroups.delete(sgKey(groupId))) persistCollapsedGroups();
|
|
3935
|
+
persistSessionGroups();
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
function sgCreateGroup(name) {
|
|
3939
|
+
const group = {
|
|
3940
|
+
id: `g_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
|
3941
|
+
name: (name || '').trim() || `Group ${sessionGroups.length + 1}`,
|
|
3942
|
+
members: [],
|
|
3943
|
+
};
|
|
3944
|
+
sessionGroups.push(group);
|
|
3945
|
+
persistSessionGroups();
|
|
3946
|
+
return group;
|
|
3947
|
+
}
|
|
3948
|
+
|
|
3949
|
+
function sgDeleteGroup(id) {
|
|
3950
|
+
sessionGroups = sessionGroups.filter((g) => g.id !== id);
|
|
3951
|
+
if (collapsedProjectGroups.delete(sgKey(id))) persistCollapsedGroups();
|
|
3952
|
+
persistSessionGroups();
|
|
3953
|
+
}
|
|
3954
|
+
|
|
3955
|
+
// Move one member of a group in front of another — the user's own order inside the group.
|
|
3956
|
+
function sgReorderMember(groupId, drag, target) {
|
|
3957
|
+
const group = sgGroupById(groupId);
|
|
3958
|
+
if (!group) return;
|
|
3959
|
+
const from = group.members.findIndex((m) => m.type === drag.kind && m.ref === drag.ref);
|
|
3960
|
+
const to = group.members.findIndex((m) => m.type === target.type && m.ref === target.ref);
|
|
3961
|
+
if (from < 0 || to < 0 || from === to) return;
|
|
3962
|
+
const [member] = group.members.splice(from, 1);
|
|
3963
|
+
group.members.splice(to, 0, member);
|
|
3964
|
+
persistSessionGroups();
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
function sgReorderGroup(dragId, targetId) {
|
|
3968
|
+
const from = sessionGroups.findIndex((g) => g.id === dragId);
|
|
3969
|
+
const to = sessionGroups.findIndex((g) => g.id === targetId);
|
|
3970
|
+
if (from < 0 || to < 0 || from === to) return;
|
|
3971
|
+
const [g] = sessionGroups.splice(from, 1);
|
|
3972
|
+
sessionGroups.splice(to, 0, g);
|
|
3973
|
+
persistSessionGroups();
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3976
|
+
// Typing a group name should surface the sessions inside it.
|
|
3977
|
+
function sgSearchMatchIds(query) {
|
|
3978
|
+
const ids = new Set();
|
|
3979
|
+
if (!query) return ids;
|
|
3980
|
+
const hits = sessionGroups.filter((g) => fuzzyMatch(g.name, query));
|
|
3981
|
+
if (hits.length === 0) return ids;
|
|
3982
|
+
const hitIds = new Set(hits.map((g) => g.id));
|
|
3983
|
+
for (const s of sessions) {
|
|
3984
|
+
const group = sgGroupForSession(s);
|
|
3985
|
+
if (group && hitIds.has(group.id)) ids.add(s.id);
|
|
3986
|
+
}
|
|
3987
|
+
return ids;
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3990
|
+
function sgHeaderHtml(group, countHtml) {
|
|
3991
|
+
const collapsed = collapsedProjectGroups.has(sgKey(group.id));
|
|
3992
|
+
const editing = sgEditingId === group.id;
|
|
3993
|
+
const nameHtml = editing
|
|
3994
|
+
? `<input class="sg-name-input" type="text" value="${escapeHtml(group.name)}" aria-label="Group name" spellcheck="false">`
|
|
3995
|
+
: `<span class="group-name">${escapeHtml(group.name)}</span>`;
|
|
3996
|
+
return `
|
|
3997
|
+
<div class="session-group-header${collapsed ? ' collapsed' : ''}" draggable="${editing ? 'false' : 'true'}" data-group-path="${escapeHtml(sgKey(group.id))}" data-group-id="${escapeHtml(group.id)}" title="${escapeHtml(group.name)} — drop sessions or projects here">
|
|
3998
|
+
${groupChevronSvg()}
|
|
3999
|
+
${nameHtml}
|
|
4000
|
+
${countHtml}
|
|
4001
|
+
<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>
|
|
4002
|
+
<span class="sg-action sg-delete" title="Delete group (members return to Projects)">×</span>
|
|
4003
|
+
</div>`;
|
|
4004
|
+
}
|
|
4005
|
+
|
|
4006
|
+
//#region SESSION_GROUPS_RENAME
|
|
4007
|
+
// The name is edited in place. While the field is live, renderSessions() defers (an SSE
|
|
4008
|
+
// refresh would otherwise replace the input mid-keystroke) — see the guard in renderSessions.
|
|
4009
|
+
let sgEditingId = null;
|
|
4010
|
+
|
|
4011
|
+
function sgIsEditing() {
|
|
4012
|
+
return !!sgEditingId && !!sessionsList.querySelector('.sg-name-input');
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
function sgBeginRename(id) {
|
|
4016
|
+
sgEditingId = id;
|
|
4017
|
+
renderSessions();
|
|
4018
|
+
const input = sessionsList.querySelector('.sg-name-input');
|
|
4019
|
+
if (input) input.select();
|
|
4020
|
+
else sgEditingId = null;
|
|
4021
|
+
}
|
|
4022
|
+
|
|
4023
|
+
function sgCommitRename(input, save) {
|
|
4024
|
+
const group = sgGroupById(sgEditingId);
|
|
4025
|
+
const value = input.value.trim();
|
|
4026
|
+
sgEditingId = null;
|
|
4027
|
+
if (save && group && value && value !== group.name) {
|
|
4028
|
+
group.name = value;
|
|
4029
|
+
persistSessionGroups();
|
|
4030
|
+
}
|
|
4031
|
+
renderSessions();
|
|
4032
|
+
}
|
|
4033
|
+
//#endregion
|
|
4034
|
+
|
|
4035
|
+
//#region SESSION_GROUPS_DND
|
|
4036
|
+
// At most one zone is lit, so the reference is enough - dragover fires many times a second.
|
|
4037
|
+
let sgLitZone = null;
|
|
4038
|
+
|
|
4039
|
+
function sgClearDropTargets() {
|
|
4040
|
+
sgLitZone?.classList.remove('sg-drop-over');
|
|
4041
|
+
sgLitZone = null;
|
|
4042
|
+
}
|
|
4043
|
+
|
|
4044
|
+
function sgLightDropTarget(zone) {
|
|
4045
|
+
if (sgLitZone === zone) return;
|
|
4046
|
+
sgClearDropTargets();
|
|
4047
|
+
zone.classList.add('sg-drop-over');
|
|
4048
|
+
sgLitZone = zone;
|
|
4049
|
+
}
|
|
4050
|
+
|
|
4051
|
+
function sgDragPayload(el) {
|
|
4052
|
+
if (el.classList.contains('session-group-header')) return { kind: 'group', ref: el.dataset.groupId };
|
|
4053
|
+
// data-project-path is on real project headers only, so the sentinels drop out by themselves.
|
|
4054
|
+
if (el.classList.contains('project-group-header')) {
|
|
4055
|
+
return el.dataset.projectPath ? { kind: 'project', ref: el.dataset.projectPath } : null;
|
|
4056
|
+
}
|
|
4057
|
+
if (el.classList.contains('session-item')) return { kind: 'session', ref: el.dataset.sessionId };
|
|
4058
|
+
return null;
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
// Resolve the element under the pointer to a legal drop zone for the in-flight drag.
|
|
4062
|
+
// Returns {zone} to ungroup, {zone, groupId} to assign, or {zone, pairWith}/{zone, pairSession}
|
|
4063
|
+
// to stack two ungrouped items into a brand-new group.
|
|
4064
|
+
function sgDropZone(target) {
|
|
4065
|
+
if (!sgDrag) return null;
|
|
4066
|
+
const base =
|
|
4067
|
+
'.sg-ungroup-zone, .session-group-header, .session-group-sessions, .project-group-header, .project-group-sessions';
|
|
4068
|
+
// A dragged project pairs with the project block it lands in, not with the card under the
|
|
4069
|
+
// pointer; a dragged session can stack onto another session.
|
|
4070
|
+
const zone = target.closest(sgDrag.kind === 'session' ? `.session-item, ${base}` : base);
|
|
4071
|
+
if (!zone) return null;
|
|
4072
|
+
const dragGroup = sgGroupOf(sgDrag.kind, sgDrag.ref);
|
|
4073
|
+
if (sgDrag.kind === 'group') {
|
|
4074
|
+
// A group only reorders against another group header.
|
|
4075
|
+
return zone.classList.contains('session-group-header') && zone.dataset.groupId !== sgDrag.ref
|
|
4076
|
+
? { zone, groupId: zone.dataset.groupId }
|
|
4077
|
+
: null;
|
|
4078
|
+
}
|
|
4079
|
+
if (zone.classList.contains('sg-ungroup-zone')) return dragGroup ? { zone } : null;
|
|
4080
|
+
const dragSession = sgDrag.kind === 'session' ? sessions.find((x) => x.id === sgDrag.ref) : null;
|
|
4081
|
+
const fromGroup = dragSession ? sgGroupForSession(dragSession) : dragGroup;
|
|
4082
|
+
const fromGroupId = fromGroup?.id || null;
|
|
4083
|
+
// A drop that lands on the host the session already has changes nothing, and highlighting it
|
|
4084
|
+
// promises a move that never happens.
|
|
4085
|
+
const fromHost = dragSession ? sgHostOf(fromGroup, dragSession) : null;
|
|
4086
|
+
const groupId = zone.dataset.groupId;
|
|
4087
|
+
if (groupId) {
|
|
4088
|
+
if (!dragSession) return dragGroup?.id === groupId ? null : { zone, groupId };
|
|
4089
|
+
if (fromGroupId !== groupId) return { zone, groupId };
|
|
4090
|
+
// Already in this group: its own level is a move only out of a project block.
|
|
4091
|
+
return fromHost ? { zone, groupId, loose: true } : null;
|
|
4092
|
+
}
|
|
4093
|
+
const targetSessionId = zone.dataset.sessionId;
|
|
4094
|
+
if (targetSessionId) {
|
|
4095
|
+
if (targetSessionId === sgDrag.ref) return null;
|
|
4096
|
+
const targetSession = sessions.find((s) => s.id === targetSessionId);
|
|
4097
|
+
const targetGroup = targetSession ? sgGroupForSession(targetSession) : null;
|
|
4098
|
+
const targetGroupId = targetGroup?.id || null;
|
|
4099
|
+
if (targetGroup) {
|
|
4100
|
+
// Stacking onto a card adopts that card's placement, project block included.
|
|
4101
|
+
const targetHost = sgHostOf(targetGroup, targetSession);
|
|
4102
|
+
if (targetHost) {
|
|
4103
|
+
return targetHost === fromHost && fromGroupId === targetGroupId
|
|
4104
|
+
? null
|
|
4105
|
+
: { zone, groupId: targetGroupId, under: targetHost };
|
|
4106
|
+
}
|
|
4107
|
+
// Both loose in the same group: this is a reorder, and only a member has a slot.
|
|
4108
|
+
if (fromGroupId === targetGroupId && !fromHost) {
|
|
4109
|
+
return sgGroupOf('session', targetSessionId)?.id === targetGroupId
|
|
4110
|
+
? { zone, groupId: targetGroupId, reorder: { type: 'session', ref: targetSessionId } }
|
|
4111
|
+
: null;
|
|
4112
|
+
}
|
|
4113
|
+
return { zone, groupId: targetGroupId, loose: true };
|
|
4114
|
+
}
|
|
4115
|
+
return { zone, pairSession: targetSessionId };
|
|
4116
|
+
}
|
|
4117
|
+
const path = zone.dataset.projectPath;
|
|
4118
|
+
if (!path || path === sgDrag.ref) return null;
|
|
4119
|
+
// Dropping onto a project that already sits in a group means "join that group".
|
|
4120
|
+
const pathGroup = sgGroupOf('project', path);
|
|
4121
|
+
if (pathGroup) {
|
|
4122
|
+
if (!dragSession) {
|
|
4123
|
+
return dragGroup?.id === pathGroup.id
|
|
4124
|
+
? { zone, groupId: pathGroup.id, reorder: { type: 'project', ref: path } }
|
|
4125
|
+
: { zone, groupId: pathGroup.id };
|
|
4126
|
+
}
|
|
4127
|
+
return fromHost === path && fromGroupId === pathGroup.id ? null : { zone, groupId: pathGroup.id, under: path };
|
|
4128
|
+
}
|
|
4129
|
+
// Stacking a session onto its own project block would only fold it back where it sits today.
|
|
4130
|
+
if (dragSession?.project === path) return null;
|
|
4131
|
+
return { zone, pairWith: path };
|
|
4132
|
+
}
|
|
4133
|
+
|
|
4134
|
+
function sgOnDragStart(e) {
|
|
4135
|
+
const el = e.target.closest('.session-group-header, .project-group-header, .session-item');
|
|
4136
|
+
if (!el || e.target.closest(SG_ACTION_SELECTOR)) {
|
|
4137
|
+
e.preventDefault();
|
|
4138
|
+
return;
|
|
4139
|
+
}
|
|
4140
|
+
const payload = sgDragPayload(el);
|
|
4141
|
+
if (!payload?.ref) {
|
|
4142
|
+
e.preventDefault();
|
|
4143
|
+
return;
|
|
4144
|
+
}
|
|
4145
|
+
sgDrag = payload;
|
|
4146
|
+
el.classList.add('sg-dragging');
|
|
4147
|
+
sessionsList.classList.add('sg-dragging-active');
|
|
4148
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
4149
|
+
try {
|
|
4150
|
+
e.dataTransfer.setData('text/plain', JSON.stringify(payload));
|
|
4151
|
+
} catch (_) {}
|
|
4152
|
+
}
|
|
4153
|
+
|
|
4154
|
+
function sgOnDragOver(e) {
|
|
4155
|
+
const hit = sgDropZone(e.target);
|
|
4156
|
+
// Moving onto a spot that refuses the drop has to put the previous highlight out, or two zones
|
|
4157
|
+
// stay lit and the one under the pointer is not the one that would receive the drop.
|
|
4158
|
+
if (!hit) {
|
|
4159
|
+
sgClearDropTargets();
|
|
4160
|
+
return;
|
|
4161
|
+
}
|
|
4162
|
+
e.preventDefault();
|
|
4163
|
+
e.dataTransfer.dropEffect = 'move';
|
|
4164
|
+
sgLightDropTarget(hit.zone);
|
|
4165
|
+
}
|
|
4166
|
+
|
|
4167
|
+
function sgOnDrop(e) {
|
|
4168
|
+
const hit = sgDropZone(e.target);
|
|
4169
|
+
if (!hit) return;
|
|
4170
|
+
e.preventDefault();
|
|
4171
|
+
e.stopPropagation();
|
|
4172
|
+
const drag = sgDrag;
|
|
4173
|
+
if (hit.pairWith || hit.pairSession) {
|
|
4174
|
+
// Stacked onto an ungrouped item: seed a group, then let the user name it in place.
|
|
4175
|
+
const target = hit.pairWith
|
|
4176
|
+
? { type: 'project', ref: hit.pairWith, path: hit.pairWith }
|
|
4177
|
+
: { type: 'session', ref: hit.pairSession, path: sessions.find((s) => s.id === hit.pairSession)?.project };
|
|
4178
|
+
const group = sgCreateGroup(target.path ? target.path.split(/[/\\]/).pop() : '');
|
|
4179
|
+
sgAssign(group.id, target.type, target.ref);
|
|
4180
|
+
sgAssign(group.id, drag.kind, drag.ref);
|
|
4181
|
+
sgFinishDrag();
|
|
4182
|
+
sgBeginRename(group.id);
|
|
4183
|
+
return;
|
|
4184
|
+
}
|
|
4185
|
+
if (hit.groupId) {
|
|
4186
|
+
if (hit.reorder) sgReorderMember(hit.groupId, drag, hit.reorder);
|
|
4187
|
+
else if (drag.kind === 'group') sgReorderGroup(drag.ref, hit.groupId);
|
|
4188
|
+
else sgAssign(hit.groupId, drag.kind, drag.ref, { under: hit.under, loose: hit.loose });
|
|
4189
|
+
} else {
|
|
4190
|
+
sgDetach(drag.kind, drag.ref);
|
|
4191
|
+
persistSessionGroups();
|
|
4192
|
+
}
|
|
4193
|
+
sgFinishDrag();
|
|
4194
|
+
}
|
|
4195
|
+
|
|
4196
|
+
function sgFinishDrag() {
|
|
4197
|
+
sgDrag = null;
|
|
4198
|
+
sgClearDropTargets();
|
|
4199
|
+
sessionsList.classList.remove('sg-dragging-active');
|
|
4200
|
+
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
4201
|
+
document.querySelectorAll('.sg-dragging').forEach((el) => el.classList.remove('sg-dragging'));
|
|
4202
|
+
renderSessions();
|
|
4203
|
+
}
|
|
4204
|
+
|
|
4205
|
+
function sgOnDragEnd() {
|
|
4206
|
+
if (!sgDrag) {
|
|
4207
|
+
sgClearDropTargets();
|
|
4208
|
+
sessionsList.classList.remove('sg-dragging-active');
|
|
4209
|
+
return;
|
|
4210
|
+
}
|
|
4211
|
+
sgFinishDrag();
|
|
4212
|
+
}
|
|
4213
|
+
|
|
4214
|
+
function initSessionGroupsDnd() {
|
|
4215
|
+
sessionsList.addEventListener('dragstart', sgOnDragStart);
|
|
4216
|
+
sessionsList.addEventListener('dragover', sgOnDragOver);
|
|
4217
|
+
sessionsList.addEventListener('dragleave', (e) => {
|
|
4218
|
+
const zone = e.target.closest?.('.sg-drop-over');
|
|
4219
|
+
if (zone && !zone.contains(e.relatedTarget)) sgClearDropTargets();
|
|
4220
|
+
});
|
|
4221
|
+
sessionsList.addEventListener('drop', sgOnDrop);
|
|
4222
|
+
sessionsList.addEventListener('dragend', sgOnDragEnd);
|
|
4223
|
+
sessionsList.addEventListener('contextmenu', sgOnContextMenu);
|
|
4224
|
+
sessionsList.addEventListener('keydown', (e) => {
|
|
4225
|
+
const input = e.target.closest?.('.sg-name-input');
|
|
4226
|
+
if (!input) return;
|
|
4227
|
+
// Keep the sidebar's own single-key shortcuts out of the field.
|
|
4228
|
+
e.stopPropagation();
|
|
4229
|
+
if (e.key === 'Enter') {
|
|
4230
|
+
e.preventDefault();
|
|
4231
|
+
sgCommitRename(input, true);
|
|
4232
|
+
} else if (e.key === 'Escape') {
|
|
4233
|
+
e.preventDefault();
|
|
4234
|
+
sgCommitRename(input, false);
|
|
4235
|
+
}
|
|
4236
|
+
});
|
|
4237
|
+
sessionsList.addEventListener('focusout', (e) => {
|
|
4238
|
+
const input = e.target.closest?.('.sg-name-input');
|
|
4239
|
+
if (input && sgEditingId) sgCommitRename(input, true);
|
|
4240
|
+
});
|
|
4241
|
+
// HTML5 drag-and-drop never fires on touch, and cck ships as a PWA.
|
|
4242
|
+
sessionsList.addEventListener('touchstart', sgOnTouchStart, { passive: true });
|
|
4243
|
+
sessionsList.addEventListener('touchend', sgCancelLongPress);
|
|
4244
|
+
sessionsList.addEventListener('touchmove', sgCancelLongPress, { passive: true });
|
|
4245
|
+
window.addEventListener('storage', (e) => {
|
|
4246
|
+
if (e.key !== SESSION_GROUPS_KEY) return;
|
|
4247
|
+
loadSessionGroups();
|
|
4248
|
+
renderSessions();
|
|
4249
|
+
});
|
|
4250
|
+
}
|
|
4251
|
+
//#endregion
|
|
4252
|
+
|
|
4253
|
+
//#region SESSION_GROUPS_MENU
|
|
4254
|
+
// Non-drag path: right-click or long-press a session / project header.
|
|
4255
|
+
let sgLongPressTimer = null;
|
|
4256
|
+
|
|
4257
|
+
function sgCloseMenu() {
|
|
4258
|
+
const menu = document.getElementById('sg-menu');
|
|
4259
|
+
if (menu) menu.remove();
|
|
4260
|
+
}
|
|
4261
|
+
|
|
4262
|
+
function sgOpenMenu(x, y, kind, ref) {
|
|
4263
|
+
sgCloseMenu();
|
|
4264
|
+
const current = sgGroupOf(kind, ref);
|
|
4265
|
+
const label = kind === 'project' ? 'project' : 'session';
|
|
4266
|
+
const rows = sessionGroups
|
|
4267
|
+
.filter((g) => g.id !== current?.id)
|
|
4268
|
+
.map((g) => `<button class="sg-menu-item" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(g.name)}</button>`)
|
|
4269
|
+
.join('');
|
|
4270
|
+
const menu = document.createElement('div');
|
|
4271
|
+
menu.id = 'sg-menu';
|
|
4272
|
+
menu.className = 'sg-menu';
|
|
4273
|
+
menu.dataset.sgKind = kind;
|
|
4274
|
+
menu.dataset.sgRef = ref;
|
|
4275
|
+
menu.innerHTML = `
|
|
4276
|
+
<div class="sg-menu-label">Move ${label} to group</div>
|
|
4277
|
+
${rows}
|
|
4278
|
+
<button class="sg-menu-item" data-sg-move="__new__">+ New group…</button>
|
|
4279
|
+
${current ? `<button class="sg-menu-item sg-menu-remove" data-sg-move="__none__">Remove from “${escapeHtml(current.name)}”</button>` : ''}`;
|
|
4280
|
+
document.body.appendChild(menu);
|
|
4281
|
+
const rect = menu.getBoundingClientRect();
|
|
4282
|
+
menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`;
|
|
4283
|
+
menu.style.top = `${Math.min(y, window.innerHeight - rect.height - 8)}px`;
|
|
4284
|
+
}
|
|
4285
|
+
|
|
4286
|
+
function sgOnContextMenu(e) {
|
|
4287
|
+
const el = e.target.closest('.session-item, .project-group-header');
|
|
4288
|
+
if (!el) return;
|
|
4289
|
+
const payload = sgDragPayload(el);
|
|
4290
|
+
if (!payload?.ref) return;
|
|
4291
|
+
e.preventDefault();
|
|
4292
|
+
sgOpenMenu(e.clientX, e.clientY, payload.kind, payload.ref);
|
|
4293
|
+
}
|
|
4294
|
+
|
|
4295
|
+
function sgCancelLongPress() {
|
|
4296
|
+
if (sgLongPressTimer) clearTimeout(sgLongPressTimer);
|
|
4297
|
+
sgLongPressTimer = null;
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4300
|
+
function sgOnTouchStart(e) {
|
|
4301
|
+
const el = e.target.closest('.session-item, .project-group-header');
|
|
4302
|
+
if (!el || e.target.closest(SG_ACTION_SELECTOR)) return;
|
|
4303
|
+
const payload = sgDragPayload(el);
|
|
4304
|
+
if (!payload?.ref) return;
|
|
4305
|
+
const touch = e.touches[0];
|
|
4306
|
+
sgCancelLongPress();
|
|
4307
|
+
sgLongPressTimer = setTimeout(() => {
|
|
4308
|
+
sgLongPressTimer = null;
|
|
4309
|
+
sgOpenMenu(touch.clientX, touch.clientY, payload.kind, payload.ref);
|
|
4310
|
+
}, 550);
|
|
4311
|
+
}
|
|
4312
|
+
|
|
4313
|
+
document.addEventListener('click', (e) => {
|
|
4314
|
+
const item = e.target.closest('.sg-menu-item');
|
|
4315
|
+
if (!item) {
|
|
4316
|
+
if (!e.target.closest('.sg-menu')) sgCloseMenu();
|
|
4317
|
+
return;
|
|
4318
|
+
}
|
|
4319
|
+
e.stopPropagation();
|
|
4320
|
+
const menu = item.closest('.sg-menu');
|
|
4321
|
+
const { sgKind, sgRef } = menu.dataset;
|
|
4322
|
+
const move = item.dataset.sgMove;
|
|
4323
|
+
sgCloseMenu();
|
|
4324
|
+
if (move === '__none__') {
|
|
4325
|
+
sgDetach(sgKind, sgRef);
|
|
4326
|
+
persistSessionGroups();
|
|
4327
|
+
} else if (move === '__new__') {
|
|
4328
|
+
// Name it in place rather than through a modal prompt.
|
|
4329
|
+
const group = sgCreateGroup(sgKind === 'project' ? sgRef.split(/[/\\]/).pop() : '');
|
|
4330
|
+
sgAssign(group.id, sgKind, sgRef);
|
|
4331
|
+
sgBeginRename(group.id);
|
|
4332
|
+
return;
|
|
4333
|
+
} else {
|
|
4334
|
+
sgAssign(move, sgKind, sgRef);
|
|
4335
|
+
}
|
|
4336
|
+
renderSessions();
|
|
4337
|
+
});
|
|
4338
|
+
//#endregion
|
|
4339
|
+
//#endregion
|
|
4340
|
+
|
|
3723
4341
|
//#region KEYBOARD_NAV
|
|
3724
4342
|
function selectTask(taskId, sessionId) {
|
|
3725
4343
|
clearTaskSelection();
|
|
@@ -3794,7 +4412,11 @@ function getKbId(el) {
|
|
|
3794
4412
|
}
|
|
3795
4413
|
|
|
3796
4414
|
function getGroupSessionsContainer(header) {
|
|
3797
|
-
const cls = header.classList.contains('pinned-sub-header')
|
|
4415
|
+
const cls = header.classList.contains('pinned-sub-header')
|
|
4416
|
+
? 'pinned-sub-items'
|
|
4417
|
+
: header.classList.contains('session-group-header')
|
|
4418
|
+
? 'session-group-sessions'
|
|
4419
|
+
: 'project-group-sessions';
|
|
3798
4420
|
let el = header.nextElementSibling;
|
|
3799
4421
|
while (el && !el.classList.contains(cls)) el = el.nextElementSibling;
|
|
3800
4422
|
return el;
|
|
@@ -3802,6 +4424,7 @@ function getGroupSessionsContainer(header) {
|
|
|
3802
4424
|
|
|
3803
4425
|
function getNavigableItems() {
|
|
3804
4426
|
const items = [];
|
|
4427
|
+
// A named group nests project blocks inside its body, so the walk has to recurse.
|
|
3805
4428
|
const walkGroupContainer = (container) => {
|
|
3806
4429
|
if (!container) return;
|
|
3807
4430
|
for (const child of container.children) {
|
|
@@ -3814,11 +4437,16 @@ function getNavigableItems() {
|
|
|
3814
4437
|
}
|
|
3815
4438
|
} else if (child.classList.contains('session-item')) {
|
|
3816
4439
|
items.push(child);
|
|
4440
|
+
} else if (child.classList.contains('project-group-header')) {
|
|
4441
|
+
items.push(child);
|
|
4442
|
+
if (!collapsedProjectGroups.has(child.dataset.groupPath)) {
|
|
4443
|
+
walkGroupContainer(getGroupSessionsContainer(child));
|
|
4444
|
+
}
|
|
3817
4445
|
}
|
|
3818
4446
|
}
|
|
3819
4447
|
};
|
|
3820
4448
|
for (const el of sessionsList.children) {
|
|
3821
|
-
if (el.classList.contains('project-group-header')) {
|
|
4449
|
+
if (el.classList.contains('project-group-header') || el.classList.contains('session-group-header')) {
|
|
3822
4450
|
items.push(el);
|
|
3823
4451
|
if (!collapsedProjectGroups.has(el.dataset.groupPath)) {
|
|
3824
4452
|
walkGroupContainer(getGroupSessionsContainer(el));
|
|
@@ -3907,6 +4535,9 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
3907
4535
|
activeIds.add(s.id);
|
|
3908
4536
|
if (onlyNew && (!primed || prevActiveSessionIds.has(s.id))) continue;
|
|
3909
4537
|
if (collapsedProjectGroups.delete(s.project || '__ungrouped__')) changed = true;
|
|
4538
|
+
// A named group wrapping that project (or the session itself) would keep it hidden.
|
|
4539
|
+
const group = sgGroupForSession(s);
|
|
4540
|
+
if (group && collapsedProjectGroups.delete(sgKey(group.id))) changed = true;
|
|
3910
4541
|
}
|
|
3911
4542
|
prevActiveSessionIds = activeIds;
|
|
3912
4543
|
if (changed) persistCollapsedGroups();
|
|
@@ -3914,7 +4545,11 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
3914
4545
|
}
|
|
3915
4546
|
|
|
3916
4547
|
function isGroupHeader(el) {
|
|
3917
|
-
return
|
|
4548
|
+
return (
|
|
4549
|
+
el.classList.contains('project-group-header') ||
|
|
4550
|
+
el.classList.contains('pinned-sub-header') ||
|
|
4551
|
+
el.classList.contains('session-group-header')
|
|
4552
|
+
);
|
|
3918
4553
|
}
|
|
3919
4554
|
|
|
3920
4555
|
function findParentHeader(el) {
|
|
@@ -3922,10 +4557,10 @@ function findParentHeader(el) {
|
|
|
3922
4557
|
if (subContainer?.previousElementSibling?.classList.contains('pinned-sub-header')) {
|
|
3923
4558
|
return subContainer.previousElementSibling;
|
|
3924
4559
|
}
|
|
3925
|
-
const container = el.closest('.project-group-sessions');
|
|
4560
|
+
const container = el.closest('.project-group-sessions, .session-group-sessions');
|
|
3926
4561
|
if (!container) return null;
|
|
3927
4562
|
let header = container.previousElementSibling;
|
|
3928
|
-
while (header && !
|
|
4563
|
+
while (header && !isGroupHeader(header)) header = header.previousElementSibling;
|
|
3929
4564
|
return header;
|
|
3930
4565
|
}
|
|
3931
4566
|
|
|
@@ -3939,6 +4574,12 @@ function handleSidebarHorizontal(direction) {
|
|
|
3939
4574
|
const isCollapsed = collapsedProjectGroups.has(el.dataset.groupPath);
|
|
3940
4575
|
if (collapse) {
|
|
3941
4576
|
if (!isCollapsed) setGroupCollapsed(el, true);
|
|
4577
|
+
else {
|
|
4578
|
+
// Already closed — step out to the named group that holds this block, if any.
|
|
4579
|
+
const parent = findParentHeader(el);
|
|
4580
|
+
const parentIdx = parent ? items.indexOf(parent) : -1;
|
|
4581
|
+
if (parentIdx >= 0) selectSessionByIndex(parentIdx, items);
|
|
4582
|
+
}
|
|
3942
4583
|
} else if (isCollapsed) {
|
|
3943
4584
|
setGroupCollapsed(el, false);
|
|
3944
4585
|
} else {
|
|
@@ -4836,13 +5477,14 @@ function _renderStorageLinkedDocs() {
|
|
|
4836
5477
|
const { groups, orphans } = _groupByProject(entries.map((e) => e.sessionId));
|
|
4837
5478
|
|
|
4838
5479
|
function renderDocRow(sessionId, p) {
|
|
4839
|
-
const name = p
|
|
5480
|
+
const name = linkedDocLabel(p);
|
|
4840
5481
|
const sid = escAttrJs(sessionId);
|
|
4841
5482
|
const jsPath = escAttrJs(p);
|
|
4842
5483
|
return `<div class="storage-item" style="padding-left:24px;">
|
|
4843
5484
|
<span class="storage-item-id" title="${escapeHtml(p)}">${escapeHtml(name)}</span>
|
|
4844
5485
|
<div class="storage-item-actions">
|
|
4845
5486
|
<button onclick="_storagePreviewLinkedDoc('${jsPath}')">View</button>
|
|
5487
|
+
<button onclick="copyWithFeedback('${jsPath}', this)" title="Copy path" aria-label="Copy path">${ICON_COPY}</button>
|
|
4846
5488
|
<button class="danger" onclick="_storageUnlinkDoc('${sid}','${jsPath}')">Unlink</button>
|
|
4847
5489
|
</div>
|
|
4848
5490
|
</div>`;
|
|
@@ -4879,7 +5521,7 @@ function _renderStorageLinkedDocs() {
|
|
|
4879
5521
|
}
|
|
4880
5522
|
|
|
4881
5523
|
function _storagePreviewLinkedDoc(path) {
|
|
4882
|
-
|
|
5524
|
+
openLinkedDoc(path);
|
|
4883
5525
|
}
|
|
4884
5526
|
|
|
4885
5527
|
function _storageUnlinkDoc(sessionId, path) {
|
|
@@ -5541,21 +6183,74 @@ function isPreviewablePath(p) {
|
|
|
5541
6183
|
return /\.(md|markdown|html?)$/i.test(p);
|
|
5542
6184
|
}
|
|
5543
6185
|
|
|
6186
|
+
// A scratchpad manifest opens in the external `scratch` viewer, not the editor —
|
|
6187
|
+
// the raw JSON is a manifest, not the document the user linked.
|
|
6188
|
+
function isScratchpadPath(p) {
|
|
6189
|
+
return /(^|[\\/])scratchpad\.json$/i.test(p);
|
|
6190
|
+
}
|
|
6191
|
+
|
|
6192
|
+
// `linkedDocOpener` is the only place a linked path is classified; the row's tag,
|
|
6193
|
+
// tooltip and action are all read from here, so a new destination is one entry.
|
|
6194
|
+
const LINKED_DOC_OPENERS = {
|
|
6195
|
+
preview: { tag: '', hint: '' },
|
|
6196
|
+
scratch: {
|
|
6197
|
+
tag: '(scratchpad)',
|
|
6198
|
+
hint: ' — opens in the scratch viewer',
|
|
6199
|
+
url: '/api/scratchpad/open',
|
|
6200
|
+
body: (p) => ({ path: p }),
|
|
6201
|
+
toast: 'in the scratch viewer',
|
|
6202
|
+
},
|
|
6203
|
+
editor: {
|
|
6204
|
+
tag: '(editor)',
|
|
6205
|
+
hint: ' — opens in editor',
|
|
6206
|
+
url: '/api/open-in-editor',
|
|
6207
|
+
body: (p) => ({ file: p }),
|
|
6208
|
+
toast: 'in editor',
|
|
6209
|
+
},
|
|
6210
|
+
};
|
|
6211
|
+
|
|
6212
|
+
function linkedDocOpener(p) {
|
|
6213
|
+
if (isPreviewablePath(p)) return 'preview';
|
|
6214
|
+
// Without the CLI there is nothing to launch, so the manifest falls back to the editor.
|
|
6215
|
+
if (isScratchpadPath(p) && appConfig.scratchAvailable) return 'scratch';
|
|
6216
|
+
return 'editor';
|
|
6217
|
+
}
|
|
6218
|
+
|
|
6219
|
+
// Every manifest is called scratchpad.json, so the pad's folder names it. Kept
|
|
6220
|
+
// independent of the opener: the pad is still the pad when the CLI is missing.
|
|
6221
|
+
function linkedDocLabel(p) {
|
|
6222
|
+
const parts = p.split(/[\\/]/).filter(Boolean);
|
|
6223
|
+
return isScratchpadPath(p) ? parts[parts.length - 2] || parts[parts.length - 1] : parts[parts.length - 1];
|
|
6224
|
+
}
|
|
6225
|
+
|
|
6226
|
+
function openLinkedDoc(p, baseDir) {
|
|
6227
|
+
const opener = linkedDocOpener(p);
|
|
6228
|
+
if (opener === 'preview') {
|
|
6229
|
+
openPreviewByPath(p, baseDir);
|
|
6230
|
+
return;
|
|
6231
|
+
}
|
|
6232
|
+
const { url, body, toast } = LINKED_DOC_OPENERS[opener];
|
|
6233
|
+
postAndToast(url, body(p), toast);
|
|
6234
|
+
}
|
|
6235
|
+
|
|
5544
6236
|
function renderLinkedDocsHtml(sessionId) {
|
|
5545
6237
|
const paths = getSessionPreviewPaths(sessionId);
|
|
5546
6238
|
const baseDir = getSessionBaseDir(sessionId);
|
|
5547
6239
|
const items = paths
|
|
5548
6240
|
.map((p) => {
|
|
5549
|
-
const
|
|
6241
|
+
const opener = linkedDocOpener(p);
|
|
6242
|
+
const { tag, hint } = LINKED_DOC_OPENERS[opener];
|
|
6243
|
+
const name = linkedDocLabel(p);
|
|
5550
6244
|
const rel = baseDir ? toRelativeIfUnder(p, baseDir) : null;
|
|
5551
|
-
const previewable = isPreviewablePath(p);
|
|
5552
|
-
const title = previewable ? p : `${p} — opens in editor`;
|
|
5553
6245
|
const pathSpan = rel ? `<span class="linked-doc-path" title="${escapeHtml(p)}">${escapeHtml(rel)}</span>` : '';
|
|
5554
6246
|
const attr = escapeHtml(p);
|
|
5555
|
-
return `<li class="linked-doc-item${
|
|
5556
|
-
<a href="#" class="linked-doc-link" data-path="${attr}" title="${escapeHtml(
|
|
5557
|
-
${pathSpan}${
|
|
5558
|
-
<
|
|
6247
|
+
return `<li class="linked-doc-item${opener === 'preview' ? '' : ' is-editor'}">
|
|
6248
|
+
<a href="#" class="linked-doc-link" data-path="${attr}" title="${escapeHtml(p + hint)}">${escapeHtml(name)}</a>
|
|
6249
|
+
${pathSpan}${tag ? `<span class="linked-doc-path">${tag}</span>` : ''}
|
|
6250
|
+
<span class="row-actions linked-doc-actions">
|
|
6251
|
+
<button type="button" class="linked-doc-copy" data-path="${attr}" title="Copy path" aria-label="Copy path of ${escapeHtml(name)}">${ICON_COPY}</button>
|
|
6252
|
+
<button type="button" class="linked-doc-remove" data-path="${attr}" title="Unlink" aria-label="Unlink ${escapeHtml(name)}">×</button>
|
|
6253
|
+
</span>
|
|
5559
6254
|
</li>`;
|
|
5560
6255
|
})
|
|
5561
6256
|
.join('');
|
|
@@ -5580,18 +6275,18 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
5580
6275
|
function bindLinkedDocsHandlers(container, sessionId) {
|
|
5581
6276
|
if (!container) return;
|
|
5582
6277
|
container.addEventListener('click', (e) => {
|
|
5583
|
-
const hit = e.target.closest('.linked-doc-link, .linked-doc-remove, .linked-docs-add-btn');
|
|
6278
|
+
const hit = e.target.closest('.linked-doc-link, .linked-doc-copy, .linked-doc-remove, .linked-docs-add-btn');
|
|
5584
6279
|
if (!hit) return;
|
|
5585
6280
|
e.preventDefault();
|
|
5586
6281
|
if (hit.classList.contains('linked-docs-add-btn')) {
|
|
5587
6282
|
startLinkedDocInput(container, sessionId);
|
|
6283
|
+
} else if (hit.classList.contains('linked-doc-copy')) {
|
|
6284
|
+
copyWithFeedback(hit.dataset.path, hit);
|
|
5588
6285
|
} else if (hit.classList.contains('linked-doc-remove')) {
|
|
5589
6286
|
removeSessionPreviewPath(sessionId, hit.dataset.path);
|
|
5590
6287
|
afterLinkedDocsChanged(sessionId);
|
|
5591
|
-
} else if (isPreviewablePath(hit.dataset.path)) {
|
|
5592
|
-
openPreviewByPath(hit.dataset.path, getSessionBaseDir(sessionId));
|
|
5593
6288
|
} else {
|
|
5594
|
-
|
|
6289
|
+
openLinkedDoc(hit.dataset.path, getSessionBaseDir(sessionId));
|
|
5595
6290
|
}
|
|
5596
6291
|
});
|
|
5597
6292
|
}
|
|
@@ -5599,7 +6294,7 @@ function bindLinkedDocsHandlers(container, sessionId) {
|
|
|
5599
6294
|
function startLinkedDocInput(container, sessionId) {
|
|
5600
6295
|
const slot = container.querySelector('.linked-doc-editor-slot');
|
|
5601
6296
|
if (!slot) return;
|
|
5602
|
-
slot.innerHTML = `<input class="linked-doc-input" type="text" spellcheck="false" placeholder="Absolute path, or relative to the session cwd">
|
|
6297
|
+
slot.innerHTML = `<input class="linked-doc-input" type="text" spellcheck="false" placeholder="Absolute path, file:// URL, or relative to the session cwd">
|
|
5603
6298
|
<div class="linked-doc-hint">${escapeHtml(getSessionBaseDir(sessionId) || 'no session cwd — absolute paths only')}</div>
|
|
5604
6299
|
<div class="linked-doc-error"></div>
|
|
5605
6300
|
<div class="edit-actions">
|
|
@@ -6178,9 +6873,151 @@ function formatDate(dateStr) {
|
|
|
6178
6873
|
return date.toLocaleDateString();
|
|
6179
6874
|
}
|
|
6180
6875
|
|
|
6876
|
+
// Terminal output reaches the transcript raw. A CSI-only pattern left OSC
|
|
6877
|
+
// sequences, 8-bit CSI and bare control bytes behind, and those render as tofu.
|
|
6878
|
+
// One ordered alternation covers every escape family, so both readers make a
|
|
6879
|
+
// single pass: OSC, SGR (the only one worth keeping — capture group 1 holds its
|
|
6880
|
+
// parameters), any other CSI, other two-char escapes, then whatever control
|
|
6881
|
+
// bytes are left, lone introducers included. \t \n \r are deliberately absent.
|
|
6882
|
+
const ANSI_TOKEN_RE =
|
|
6883
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: escape and control bytes are the target
|
|
6884
|
+
/\x1b\][\s\S]*?(?:\x07|\x1b\\|$)|[\x1b\x9b]\[([0-9;]*)m|[\x1b\x9b]\[[0-?]*[ -/]*[@-~]|\x1b[@-Z\\-_]|\x1b[ -/]+[0-~]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x9b]/g;
|
|
6885
|
+
|
|
6181
6886
|
function stripAnsi(text) {
|
|
6182
|
-
|
|
6183
|
-
return
|
|
6887
|
+
if (typeof text !== 'string') return text;
|
|
6888
|
+
return text.replace(ANSI_TOKEN_RE, '');
|
|
6889
|
+
}
|
|
6890
|
+
|
|
6891
|
+
const ANSI_COLOR_NAMES = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
|
|
6892
|
+
const XTERM_CUBE = [0, 95, 135, 175, 215, 255];
|
|
6893
|
+
|
|
6894
|
+
// xterm-256 index -> a colour the browser understands. 0-15 stay named so they
|
|
6895
|
+
// follow the theme's palette; 16+ are exact and become an inline rgb().
|
|
6896
|
+
function ansi256Color(n) {
|
|
6897
|
+
if (n < 8) return { name: ANSI_COLOR_NAMES[n] };
|
|
6898
|
+
if (n < 16) return { name: `bright-${ANSI_COLOR_NAMES[n - 8]}` };
|
|
6899
|
+
if (n < 232) {
|
|
6900
|
+
const i = n - 16;
|
|
6901
|
+
return {
|
|
6902
|
+
rgb: `rgb(${XTERM_CUBE[Math.floor(i / 36) % 6]},${XTERM_CUBE[Math.floor(i / 6) % 6]},${XTERM_CUBE[i % 6]})`,
|
|
6903
|
+
};
|
|
6904
|
+
}
|
|
6905
|
+
const v = 8 + (n - 232) * 10;
|
|
6906
|
+
return { rgb: `rgb(${v},${v},${v})` };
|
|
6907
|
+
}
|
|
6908
|
+
|
|
6909
|
+
// Attribute codes that only flip a flag: code -> [field, value].
|
|
6910
|
+
const SGR_FLAGS = {
|
|
6911
|
+
1: ['bold', true],
|
|
6912
|
+
2: ['dim', true],
|
|
6913
|
+
3: ['italic', true],
|
|
6914
|
+
4: ['underline', true],
|
|
6915
|
+
7: ['inverse', true],
|
|
6916
|
+
9: ['strike', true],
|
|
6917
|
+
23: ['italic', false],
|
|
6918
|
+
24: ['underline', false],
|
|
6919
|
+
27: ['inverse', false],
|
|
6920
|
+
29: ['strike', false],
|
|
6921
|
+
};
|
|
6922
|
+
|
|
6923
|
+
// Reads one SGR parameter list into `st`. 38/48 consume their own arguments, so
|
|
6924
|
+
// the list is walked with an index rather than a for..of.
|
|
6925
|
+
function applySgr(params, st) {
|
|
6926
|
+
const codes = params === '' ? [0] : params.split(';').map((p) => (p === '' ? 0 : Number(p)));
|
|
6927
|
+
for (let i = 0; i < codes.length; i++) {
|
|
6928
|
+
const c = codes[i];
|
|
6929
|
+
const flag = SGR_FLAGS[c];
|
|
6930
|
+
// 30-37/90-97 are foreground, 40-47/100-107 background; the low digit is the
|
|
6931
|
+
// colour index and 9 in that slot means "back to the default".
|
|
6932
|
+
const isColor = (c >= 30 && c <= 49) || (c >= 90 && c <= 107);
|
|
6933
|
+
if (c === 0) {
|
|
6934
|
+
st.fg = st.bg = null;
|
|
6935
|
+
st.bold = st.dim = st.italic = st.underline = st.strike = st.inverse = false;
|
|
6936
|
+
st.plain = true;
|
|
6937
|
+
} else if (flag) {
|
|
6938
|
+
st[flag[0]] = flag[1];
|
|
6939
|
+
st.plain = false;
|
|
6940
|
+
} else if (c === 22) {
|
|
6941
|
+
st.bold = st.dim = false;
|
|
6942
|
+
} else if (isColor && c !== 38 && c !== 48) {
|
|
6943
|
+
const key = (c >= 40 && c <= 49) || c >= 100 ? 'bg' : 'fg';
|
|
6944
|
+
const idx = c % 10;
|
|
6945
|
+
st[key] = idx === 9 ? null : { name: c >= 90 ? `bright-${ANSI_COLOR_NAMES[idx]}` : ANSI_COLOR_NAMES[idx] };
|
|
6946
|
+
if (idx !== 9) st.plain = false;
|
|
6947
|
+
} else if (c === 38 || c === 48) {
|
|
6948
|
+
st.plain = false;
|
|
6949
|
+
const key = c === 38 ? 'fg' : 'bg';
|
|
6950
|
+
if (codes[i + 1] === 5) {
|
|
6951
|
+
st[key] = ansi256Color(codes[i + 2] || 0);
|
|
6952
|
+
i += 2;
|
|
6953
|
+
} else if (codes[i + 1] === 2) {
|
|
6954
|
+
st[key] = { rgb: `rgb(${codes[i + 2] || 0},${codes[i + 3] || 0},${codes[i + 4] || 0})` };
|
|
6955
|
+
i += 4;
|
|
6956
|
+
}
|
|
6957
|
+
}
|
|
6958
|
+
}
|
|
6959
|
+
}
|
|
6960
|
+
|
|
6961
|
+
function wrapAnsiChunk(chunk, st) {
|
|
6962
|
+
const escaped = escapeHtml(chunk);
|
|
6963
|
+
if (st.plain) return escaped;
|
|
6964
|
+
const classes = [];
|
|
6965
|
+
const styles = [];
|
|
6966
|
+
const fg = st.inverse ? st.bg : st.fg;
|
|
6967
|
+
const bg = st.inverse ? st.fg : st.bg;
|
|
6968
|
+
if (fg?.name) classes.push(`ansi-fg-${fg.name}`);
|
|
6969
|
+
else if (fg?.rgb) styles.push(`color:${fg.rgb}`);
|
|
6970
|
+
// `ansi-bg` carries the padding both background forms share; the named form
|
|
6971
|
+
// adds the colour class, the 256/truecolor form an inline background.
|
|
6972
|
+
if (bg?.name) classes.push('ansi-bg', `ansi-bg-${bg.name}`);
|
|
6973
|
+
else if (bg?.rgb) {
|
|
6974
|
+
classes.push('ansi-bg');
|
|
6975
|
+
styles.push(`background:${bg.rgb}`);
|
|
6976
|
+
}
|
|
6977
|
+
if (st.inverse && !fg && !bg) classes.push('ansi-inverse');
|
|
6978
|
+
if (st.bold) classes.push('ansi-bold');
|
|
6979
|
+
if (st.dim) classes.push('ansi-dim');
|
|
6980
|
+
if (st.italic) classes.push('ansi-italic');
|
|
6981
|
+
if (st.underline) classes.push('ansi-underline');
|
|
6982
|
+
if (st.strike) classes.push('ansi-strike');
|
|
6983
|
+
if (!classes.length && !styles.length) return escaped;
|
|
6984
|
+
// Both values are built from fixed tables and digits parsed out of the SGR
|
|
6985
|
+
// parameters, but they land in an attribute, so they go through the escaper.
|
|
6986
|
+
const cls = classes.length ? ` class="${escapeHtml(classes.join(' '))}"` : '';
|
|
6987
|
+
const style = styles.length ? ` style="${escapeHtml(styles.join(';'))}"` : '';
|
|
6988
|
+
return `<span${cls}${style}>${escaped}</span>`;
|
|
6989
|
+
}
|
|
6990
|
+
|
|
6991
|
+
// Renders terminal output as HTML with its SGR colours intact. One pass over
|
|
6992
|
+
// ANSI_TOKEN_RE: SGR tokens (capture group 1) move the state, every other token
|
|
6993
|
+
// is noise and is dropped, and the text between them is a chunk. Returns escaped
|
|
6994
|
+
// HTML — every chunk goes through escapeHtml before it is wrapped.
|
|
6995
|
+
function ansiToHtml(text) {
|
|
6996
|
+
if (typeof text !== 'string') return '';
|
|
6997
|
+
const st = {
|
|
6998
|
+
fg: null,
|
|
6999
|
+
bg: null,
|
|
7000
|
+
bold: false,
|
|
7001
|
+
dim: false,
|
|
7002
|
+
italic: false,
|
|
7003
|
+
underline: false,
|
|
7004
|
+
strike: false,
|
|
7005
|
+
inverse: false,
|
|
7006
|
+
plain: true,
|
|
7007
|
+
};
|
|
7008
|
+
let out = '';
|
|
7009
|
+
let last = 0;
|
|
7010
|
+
ANSI_TOKEN_RE.lastIndex = 0;
|
|
7011
|
+
let m = ANSI_TOKEN_RE.exec(text);
|
|
7012
|
+
while (m) {
|
|
7013
|
+
if (m.index > last) out += wrapAnsiChunk(text.slice(last, m.index), st);
|
|
7014
|
+
if (m[1] !== undefined) applySgr(m[1], st);
|
|
7015
|
+
last = m.index + m[0].length;
|
|
7016
|
+
m = ANSI_TOKEN_RE.exec(text);
|
|
7017
|
+
}
|
|
7018
|
+
if (last === 0) return escapeHtml(text);
|
|
7019
|
+
if (last < text.length) out += wrapAnsiChunk(text.slice(last), st);
|
|
7020
|
+
return out;
|
|
6184
7021
|
}
|
|
6185
7022
|
|
|
6186
7023
|
function stripTeammateWrapper(text) {
|
|
@@ -6558,13 +7395,27 @@ document.addEventListener('click', (e) => {
|
|
|
6558
7395
|
return;
|
|
6559
7396
|
}
|
|
6560
7397
|
|
|
6561
|
-
const
|
|
6562
|
-
if (
|
|
6563
|
-
|
|
6564
|
-
|
|
7398
|
+
const sgHeader = e.target.closest('.session-group-header');
|
|
7399
|
+
if (sgHeader) {
|
|
7400
|
+
if (e.target.closest('.sg-name-input')) return;
|
|
7401
|
+
e.stopPropagation();
|
|
7402
|
+
const groupId = sgHeader.dataset.groupId;
|
|
7403
|
+
const group = sgGroupById(groupId);
|
|
7404
|
+
if (!group) return;
|
|
7405
|
+
if (e.target.closest('.sg-rename')) {
|
|
7406
|
+
sgBeginRename(groupId);
|
|
7407
|
+
return;
|
|
7408
|
+
}
|
|
7409
|
+
if (e.target.closest('.sg-delete')) {
|
|
7410
|
+
if (confirm(`Delete group “${group.name}”? Its sessions and projects go back to Projects.`)) {
|
|
7411
|
+
sgDeleteGroup(groupId);
|
|
7412
|
+
renderSessions();
|
|
7413
|
+
}
|
|
7414
|
+
return;
|
|
7415
|
+
}
|
|
6565
7416
|
}
|
|
6566
7417
|
|
|
6567
|
-
const header = e.target.closest('.project-group-header');
|
|
7418
|
+
const header = e.target.closest('.session-group-header, .pinned-sub-header, .project-group-header');
|
|
6568
7419
|
if (header) {
|
|
6569
7420
|
setGroupCollapsed(header, !collapsedProjectGroups.has(header.dataset.groupPath));
|
|
6570
7421
|
}
|
|
@@ -7024,7 +7875,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
7024
7875
|
const file = opts.openFile ? escapeHtml(opts.openFile) : '';
|
|
7025
7876
|
openBtn = `<button data-folder="${folder}" data-file="${file}" data-claude-dir="${opts.openClaudeDir ? '1' : ''}" onclick="openFolderInEditor(this.dataset.claudeDir ? undefined : this.dataset.folder, this.dataset.file || undefined)" title="Open in editor">${ICON_OPEN_EXTERNAL}</button>`;
|
|
7026
7877
|
}
|
|
7027
|
-
html += `<span class="
|
|
7878
|
+
html += `<span class="row-actions">${copyBtn}${openBtn}</span>`;
|
|
7028
7879
|
});
|
|
7029
7880
|
html += `</div>`;
|
|
7030
7881
|
|
|
@@ -7287,7 +8138,7 @@ function renderGoalSubtitle(session) {
|
|
|
7287
8138
|
// Only active (unmet) goals reach here — a met goal auto-clears. Clicking
|
|
7288
8139
|
// opens the info modal (full text); stopPropagation so it doesn't also
|
|
7289
8140
|
// trigger the card's fetchTasks.
|
|
7290
|
-
return `<div class="session-goal" onclick="event.stopPropagation(); showSessionInfoModal('${escAttrJs(session.id)}')" title="${escapeHtml(g.condition)}"><span class="session-goal-icon">◎</span>${escapeHtml(short)}</div>`;
|
|
8141
|
+
return `<div class="session-goal" onclick="event.stopPropagation(); showSessionInfoModal('${escAttrJs(session.id)}')" title="${escapeHtml(g.condition)}"><span class="session-goal-icon">◎</span><span class="session-goal-text">${escapeHtml(short)}</span></div>`;
|
|
7291
8142
|
}
|
|
7292
8143
|
|
|
7293
8144
|
function renderLoopBadge(session) {
|
|
@@ -7811,6 +8662,8 @@ try {
|
|
|
7811
8662
|
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
7812
8663
|
cg.forEach((p) => collapsedProjectGroups.add(p));
|
|
7813
8664
|
} catch (_) {}
|
|
8665
|
+
loadSessionGroups();
|
|
8666
|
+
initSessionGroupsDnd();
|
|
7814
8667
|
try {
|
|
7815
8668
|
const af = JSON.parse(localStorage.getItem('activityFilter') || '[]');
|
|
7816
8669
|
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|