claude-code-kanban 4.21.0 → 4.23.1
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 +38 -0
- package/package.json +1 -1
- package/public/app.js +991 -137
- package/public/style.css +470 -94
- 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;
|
|
@@ -1458,8 +1460,8 @@ function getToolIcon(toolName) {
|
|
|
1458
1460
|
}
|
|
1459
1461
|
const AGENT_LOG_ICON =
|
|
1460
1462
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
|
1461
|
-
function agentLogButton(agentId) {
|
|
1462
|
-
return `<button class="
|
|
1463
|
+
function agentLogButton(agentId, cls = 'msg-agent-log-btn') {
|
|
1464
|
+
return `<button class="${escapeHtml(cls)}" onclick="event.stopPropagation();viewAgentLog('${escAttrJs(agentId)}')" title="View agent log">${AGENT_LOG_ICON}</button>`;
|
|
1463
1465
|
}
|
|
1464
1466
|
|
|
1465
1467
|
function getPinId(m) {
|
|
@@ -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
|
|
|
@@ -2750,9 +2763,14 @@ function renderAgentFooter() {
|
|
|
2750
2763
|
const agentColor = resolveNamedColor(a.color);
|
|
2751
2764
|
const colorStyle = agentColor ? ` style="border-left:3px solid ${agentColor.color}"` : '';
|
|
2752
2765
|
const selectedClass = a.agentId === currentAgentModalId ? ' selected' : '';
|
|
2766
|
+
const cardModel = shortModelName(a.model);
|
|
2767
|
+
const modelChipHtml = cardModel
|
|
2768
|
+
? `<span class="agent-card-model" title="${escapeHtml(a.model)}">${escapeHtml(cardModel)}</span>`
|
|
2769
|
+
: '';
|
|
2753
2770
|
return `<div class="agent-card${selectedClass}" data-agent-id="${escapeHtml(a.agentId)}"${colorStyle} onclick="showAgentModal('${escAttrJs(a.agentId)}')">
|
|
2771
|
+
${agentLogButton(a.agentId, 'agent-card-log-btn')}
|
|
2754
2772
|
<div class="agent-type-row">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}<span class="agent-type-name">${escapeHtml(typeName)}</span>${nameBadgeHtml}</div>
|
|
2755
|
-
<div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span
|
|
2773
|
+
<div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span>${modelChipHtml}</div>
|
|
2756
2774
|
${msgHtml}
|
|
2757
2775
|
</div>`;
|
|
2758
2776
|
})
|
|
@@ -3002,6 +3020,9 @@ function renderAllTasks() {
|
|
|
3002
3020
|
}
|
|
3003
3021
|
|
|
3004
3022
|
function renderSessions() {
|
|
3023
|
+
// Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
|
|
3024
|
+
// half-typed group name, and the SSE path can fire at any moment — defer instead.
|
|
3025
|
+
if (sgDrag || sgIsEditing()) return;
|
|
3005
3026
|
// Update project dropdown
|
|
3006
3027
|
updateProjectDropdown();
|
|
3007
3028
|
|
|
@@ -3054,12 +3075,14 @@ function renderSessions() {
|
|
|
3054
3075
|
)
|
|
3055
3076
|
taskMatchIds.add(t.sessionId);
|
|
3056
3077
|
}
|
|
3078
|
+
const groupMatchIds = sgSearchMatchIds(searchQuery);
|
|
3057
3079
|
const matchesSearch = (s) =>
|
|
3058
3080
|
(s.name && fuzzyMatch(s.name, searchQuery)) ||
|
|
3059
3081
|
(s.id && fuzzyMatch(s.id, searchQuery)) ||
|
|
3060
3082
|
(s.project && fuzzyMatch(s.project, searchQuery)) ||
|
|
3061
3083
|
(s.description && fuzzyMatch(s.description, searchQuery)) ||
|
|
3062
|
-
taskMatchIds.has(s.id)
|
|
3084
|
+
taskMatchIds.has(s.id) ||
|
|
3085
|
+
groupMatchIds.has(s.id);
|
|
3063
3086
|
|
|
3064
3087
|
filteredSessions = filteredSessions.filter(matchesSearch);
|
|
3065
3088
|
|
|
@@ -3119,9 +3142,9 @@ function renderSessions() {
|
|
|
3119
3142
|
const total = session.taskCount;
|
|
3120
3143
|
const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
|
|
3121
3144
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3145
|
+
// hasRecentLog carries the server's registry-idle correction: an open-but-idle
|
|
3146
|
+
// terminal keeps touching its JSONL, so mtime alone is not proof of activity.
|
|
3147
|
+
const isLive = session.hasRecentLog && Date.now() - Date.parse(session.modifiedAt) <= LIVE_INDICATOR_MS;
|
|
3125
3148
|
const rawName = session.name || session.id;
|
|
3126
3149
|
const sessionName = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(rawName)
|
|
3127
3150
|
? rawName.slice(0, 8)
|
|
@@ -3152,7 +3175,7 @@ function renderSessions() {
|
|
|
3152
3175
|
const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
|
|
3153
3176
|
const sid = escAttrJs(session.id);
|
|
3154
3177
|
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)}">
|
|
3178
|
+
<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
3179
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
3157
3180
|
<div class="session-name">${escapeHtml(primaryName)}</div>
|
|
3158
3181
|
${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
|
|
@@ -3184,13 +3207,162 @@ function renderSessions() {
|
|
|
3184
3207
|
`;
|
|
3185
3208
|
};
|
|
3186
3209
|
|
|
3210
|
+
const groupPinned = localStorage.getItem('groupPinnedSessions') !== 'false';
|
|
3211
|
+
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isPlacedPinned(s.id) && !isSessionActive(s) ? 1 : 0);
|
|
3212
|
+
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
3213
|
+
const countHtml = (arr) => {
|
|
3214
|
+
const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
3215
|
+
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>`;
|
|
3216
|
+
};
|
|
3217
|
+
const renderGroupSessions = (sessions, pinKey) => {
|
|
3218
|
+
if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
|
|
3219
|
+
const gPinned = sessions.filter((s) => isPlacedPinned(s.id) && !isPlacedSticky(s.id));
|
|
3220
|
+
if (gPinned.length === 0) return sessions.map(renderSessionCard).join('');
|
|
3221
|
+
const gIdlePinned = gPinned.filter((s) => !isSessionActive(s));
|
|
3222
|
+
const gUnpinned = sessions.filter((s) => !isPlacedPinned(s.id) || isSessionActive(s) || isPlacedSticky(s.id));
|
|
3223
|
+
const pinCollapsed = collapsedProjectGroups.has(pinKey);
|
|
3224
|
+
if (gIdlePinned.length === 0 && !pinCollapsed) return gUnpinned.map(renderSessionCard).join('');
|
|
3225
|
+
return (
|
|
3226
|
+
'<div class="pinned-sub-section">' +
|
|
3227
|
+
'<div class="pinned-sub-header' +
|
|
3228
|
+
(pinCollapsed ? ' collapsed' : '') +
|
|
3229
|
+
'" data-group-path="' +
|
|
3230
|
+
escapeHtml(pinKey) +
|
|
3231
|
+
'">' +
|
|
3232
|
+
groupChevronSvg(10) +
|
|
3233
|
+
'<span class="pinned-sub-label">Pinned</span>' +
|
|
3234
|
+
'<span class="group-count">' +
|
|
3235
|
+
gIdlePinned.length +
|
|
3236
|
+
'</span>' +
|
|
3237
|
+
'<span class="pinned-ungroup-btn" title="Ungroup pinned sessions">×</span>' +
|
|
3238
|
+
'</div>' +
|
|
3239
|
+
'<div class="pinned-sub-items' +
|
|
3240
|
+
(pinCollapsed ? ' collapsed' : '') +
|
|
3241
|
+
'">' +
|
|
3242
|
+
gIdlePinned.map(renderSessionCard).join('') +
|
|
3243
|
+
'</div>' +
|
|
3244
|
+
'</div>' +
|
|
3245
|
+
gUnpinned.map(renderSessionCard).join('')
|
|
3246
|
+
);
|
|
3247
|
+
};
|
|
3248
|
+
|
|
3249
|
+
// Renders one project block. `nested` = it sits inside a named group, so it is indented and
|
|
3250
|
+
// its own drag payload still resolves to the project path.
|
|
3251
|
+
const projectBlock = (projectPath, projectSessions, nested) => {
|
|
3252
|
+
const folderName = projectPath.split(/[/\\]/).pop();
|
|
3253
|
+
const isCollapsed = collapsedProjectGroups.has(projectPath);
|
|
3254
|
+
const escapedPath = escapeHtml(projectPath);
|
|
3255
|
+
const nestedCls = nested ? ' sg-nested' : '';
|
|
3256
|
+
const breadcrumbParts = projectPath
|
|
3257
|
+
.replace(/^\/home\/[^/]+/, '~')
|
|
3258
|
+
.split(/[/\\]/)
|
|
3259
|
+
.filter(Boolean);
|
|
3260
|
+
const breadcrumbHtml = breadcrumbParts
|
|
3261
|
+
.map((p, i) => (i < breadcrumbParts.length - 1 ? `${escapeHtml(p)}<span class="sep">/</span>` : escapeHtml(p)))
|
|
3262
|
+
.join('');
|
|
3263
|
+
|
|
3264
|
+
return `
|
|
3265
|
+
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}${nestedCls}" draggable="true" data-group-path="${escapedPath}" data-project-path="${escapedPath}">
|
|
3266
|
+
${groupChevronSvg()}
|
|
3267
|
+
<span class="group-name">${escapeHtml(folderName)}</span>
|
|
3268
|
+
${countHtml(projectSessions)}
|
|
3269
|
+
<span class="project-view-btn" data-project-path="${escapedPath}" title="Open project view — combined tasks from all sessions">
|
|
3270
|
+
<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>
|
|
3271
|
+
</span>
|
|
3272
|
+
</div>
|
|
3273
|
+
<div class="project-group-breadcrumb${nestedCls}" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
|
|
3274
|
+
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}${nestedCls}" data-project-path="${escapedPath}">
|
|
3275
|
+
${renderGroupSessions(projectSessions, `__pinned_${projectPath}__`)}
|
|
3276
|
+
</div>
|
|
3277
|
+
`;
|
|
3278
|
+
};
|
|
3279
|
+
|
|
3280
|
+
// Named groups are a pure partition of the already-filtered list: whatever they don't claim
|
|
3281
|
+
// falls through to the project blocks below, which look exactly as they did before groups.
|
|
3282
|
+
const sgBuckets = new Map();
|
|
3283
|
+
let sgRest = filteredSessions;
|
|
3284
|
+
if (sessionGroups.length > 0) {
|
|
3285
|
+
sgRest = [];
|
|
3286
|
+
for (const session of filteredSessions) {
|
|
3287
|
+
const group = sgGroupForSession(session);
|
|
3288
|
+
if (group) {
|
|
3289
|
+
if (!sgBuckets.has(group.id)) sgBuckets.set(group.id, []);
|
|
3290
|
+
sgBuckets.get(group.id).push(session);
|
|
3291
|
+
} else {
|
|
3292
|
+
sgRest.push(session);
|
|
3293
|
+
}
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
// A group emptied by a filter hides its header; a group the user just created (no filters)
|
|
3297
|
+
// shows its drop-here state instead of vanishing.
|
|
3298
|
+
const sgFiltering = !!searchQuery || activityFilter.size > 0 || (!!filterProject && filterProject !== '__recent__');
|
|
3299
|
+
|
|
3300
|
+
// flat = the "All sessions" view, which has no project sub-blocks.
|
|
3301
|
+
const sgSectionHtml = (flat) => {
|
|
3302
|
+
if (sessionGroups.length === 0) return '';
|
|
3303
|
+
let out = '<div class="sg-section-label">Groups</div>';
|
|
3304
|
+
for (const group of sessionGroups) {
|
|
3305
|
+
const bucket = sgBuckets.get(group.id) || [];
|
|
3306
|
+
if (bucket.length === 0 && sgFiltering) continue;
|
|
3307
|
+
if (!groupPinned) bucket.sort(pinSort);
|
|
3308
|
+
const collapsed = collapsedProjectGroups.has(sgKey(group.id));
|
|
3309
|
+
let body = '';
|
|
3310
|
+
if (flat) {
|
|
3311
|
+
body = bucket.map(renderSessionCard).join('');
|
|
3312
|
+
} else {
|
|
3313
|
+
const loose = [];
|
|
3314
|
+
const byProject = new Map();
|
|
3315
|
+
for (const s of bucket) {
|
|
3316
|
+
const host = sgHostOf(group, s);
|
|
3317
|
+
if (host) {
|
|
3318
|
+
if (!byProject.has(host)) byProject.set(host, []);
|
|
3319
|
+
byProject.get(host).push(s);
|
|
3320
|
+
} else {
|
|
3321
|
+
loose.push(s);
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
// Project members first (in the user's member order), then the individually placed
|
|
3325
|
+
// sessions — one Pinned sub-section per group instead of one per interleaved run.
|
|
3326
|
+
for (const m of group.members) {
|
|
3327
|
+
if (m.type !== 'project') continue;
|
|
3328
|
+
const arr = byProject.get(m.ref);
|
|
3329
|
+
// A member project with nothing left in it still renders: it shows the membership, and
|
|
3330
|
+
// it is the drop target for putting a session back under it.
|
|
3331
|
+
if (arr?.length || !sgFiltering) body += projectBlock(m.ref, arr || [], true);
|
|
3332
|
+
}
|
|
3333
|
+
if (loose.length) {
|
|
3334
|
+
// Individually placed sessions follow the order the user dragged them into.
|
|
3335
|
+
const slot = new Map(group.members.map((m, i) => [`${m.type}:${m.ref}`, i]));
|
|
3336
|
+
loose.sort((a, b) => (slot.get(`session:${a.id}`) ?? 0) - (slot.get(`session:${b.id}`) ?? 0));
|
|
3337
|
+
// Label them, else a card sitting under the project blocks reads as stranded.
|
|
3338
|
+
if (body) body += '<div class="sg-sub-label">Sessions</div>';
|
|
3339
|
+
body += renderGroupSessions(loose, `__pinned_group_${group.id}__`);
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
if (!body) body = '<div class="sg-empty">Drop a session or a project here</div>';
|
|
3343
|
+
out += sgHeaderHtml(group, countHtml(bucket));
|
|
3344
|
+
out += `<div class="session-group-sessions${collapsed ? ' collapsed' : ''}" data-group-id="${escapeHtml(group.id)}">${body}</div>`;
|
|
3345
|
+
}
|
|
3346
|
+
return out;
|
|
3347
|
+
};
|
|
3348
|
+
|
|
3349
|
+
let html = '';
|
|
3350
|
+
if (!groupPinned && pinnedSessionIds.size > 0 && filteredSessions.some((s) => pinnedSessionIds.has(s.id))) {
|
|
3351
|
+
html += `<div class="pinned-regroup-banner">
|
|
3352
|
+
<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>
|
|
3353
|
+
Group pinned sessions
|
|
3354
|
+
</div>`;
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3187
3357
|
// Group active sessions by project
|
|
3188
3358
|
if (sessionFilter === 'active') {
|
|
3189
3359
|
// Auto-expand a collapsed section when newly-active work lands in it (live refresh).
|
|
3190
3360
|
expandActiveGroups({ onlyNew: true });
|
|
3361
|
+
html += sgSectionHtml(false);
|
|
3362
|
+
|
|
3191
3363
|
const groups = new Map();
|
|
3192
3364
|
const ungrouped = [];
|
|
3193
|
-
for (const session of
|
|
3365
|
+
for (const session of sgRest) {
|
|
3194
3366
|
if (session.project) {
|
|
3195
3367
|
if (!groups.has(session.project)) groups.set(session.project, []);
|
|
3196
3368
|
groups.get(session.project).push(session);
|
|
@@ -3198,106 +3370,42 @@ function renderSessions() {
|
|
|
3198
3370
|
ungrouped.push(session);
|
|
3199
3371
|
}
|
|
3200
3372
|
}
|
|
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
3373
|
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
3374
|
for (const [, arr] of groups) arr.sort(pinSort);
|
|
3237
3375
|
ungrouped.sort(pinSort);
|
|
3238
3376
|
}
|
|
3239
3377
|
|
|
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));
|
|
3378
|
+
// Stable group order: preserve existing order, append new groups sorted by recency.
|
|
3379
|
+
// Grouped projects stay in the array (they are just not rendered here), so dragging one
|
|
3380
|
+
// back out restores its old slot instead of jumping to the top.
|
|
3381
|
+
const latestByPath = new Map();
|
|
3382
|
+
for (const s of filteredSessions) {
|
|
3383
|
+
if (!s.project) continue;
|
|
3384
|
+
const t = new Date(s.modifiedAt).getTime();
|
|
3385
|
+
latestByPath.set(s.project, Math.max(latestByPath.get(s.project) ?? -Infinity, t));
|
|
3250
3386
|
}
|
|
3387
|
+
const knownPaths = new Set(stableGroupOrder);
|
|
3388
|
+
const keptOrder = stableGroupOrder.filter((p) => latestByPath.has(p));
|
|
3389
|
+
const newPaths = [...latestByPath.keys()].filter((p) => !knownPaths.has(p));
|
|
3390
|
+
if (newPaths.length > 1) newPaths.sort((a, b) => latestByPath.get(b) - latestByPath.get(a));
|
|
3251
3391
|
stableGroupOrder = [...keptOrder, ...newPaths];
|
|
3252
|
-
const sortedGroups = stableGroupOrder.map((p) => [p, groups.get(p)]);
|
|
3392
|
+
const sortedGroups = stableGroupOrder.filter((p) => groups.has(p)).map((p) => [p, groups.get(p)]);
|
|
3253
3393
|
|
|
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
|
-
}
|
|
3394
|
+
// Rendered even when nothing is left under it: it is the drop target for ungrouping.
|
|
3395
|
+
if (sessionGroups.length > 0) {
|
|
3396
|
+
html += '<div class="sg-section-label sg-ungroup-zone" title="Drop here to remove from a group">Projects</div>';
|
|
3263
3397
|
}
|
|
3264
3398
|
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
|
-
`;
|
|
3399
|
+
html += projectBlock(projectPath, projectSessions, false);
|
|
3291
3400
|
}
|
|
3292
3401
|
|
|
3293
3402
|
if (ungrouped.length > 0 && sortedGroups.length > 0) {
|
|
3294
3403
|
const isCollapsed = collapsedProjectGroups.has('__ungrouped__');
|
|
3295
|
-
const ungroupedActive = ungrouped.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
3296
3404
|
html += `
|
|
3297
3405
|
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__ungrouped__">
|
|
3298
|
-
|
|
3406
|
+
${groupChevronSvg()}
|
|
3299
3407
|
<span class="group-name">Ungrouped</span>
|
|
3300
|
-
|
|
3408
|
+
${countHtml(ungrouped)}
|
|
3301
3409
|
</div>
|
|
3302
3410
|
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
|
|
3303
3411
|
${renderGroupSessions(ungrouped, '__pinned___ungrouped__')}
|
|
@@ -3309,7 +3417,11 @@ function renderSessions() {
|
|
|
3309
3417
|
|
|
3310
3418
|
sessionsList.innerHTML = html;
|
|
3311
3419
|
} else {
|
|
3312
|
-
|
|
3420
|
+
const ungroupZone =
|
|
3421
|
+
sessionGroups.length > 0
|
|
3422
|
+
? '<div class="sg-section-label sg-ungroup-zone" title="Drop here to remove from a group">Sessions</div>'
|
|
3423
|
+
: '';
|
|
3424
|
+
sessionsList.innerHTML = html + sgSectionHtml(true) + ungroupZone + sgRest.map(renderSessionCard).join('');
|
|
3313
3425
|
}
|
|
3314
3426
|
|
|
3315
3427
|
const navItems = getNavigableItems();
|
|
@@ -3677,6 +3789,9 @@ function onCardDragEnd(e) {
|
|
|
3677
3789
|
|
|
3678
3790
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3679
3791
|
function onColumnDragOver(e) {
|
|
3792
|
+
// A sidebar session/project drag has nothing to do with task status — don't offer the board
|
|
3793
|
+
// as a drop target for it.
|
|
3794
|
+
if (sgDrag) return;
|
|
3680
3795
|
e.preventDefault();
|
|
3681
3796
|
e.dataTransfer.dropEffect = 'move';
|
|
3682
3797
|
e.currentTarget.classList.add('drag-over');
|
|
@@ -3691,6 +3806,7 @@ function onColumnDragLeave(e) {
|
|
|
3691
3806
|
|
|
3692
3807
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3693
3808
|
async function onColumnDrop(e) {
|
|
3809
|
+
if (sgDrag) return;
|
|
3694
3810
|
e.preventDefault();
|
|
3695
3811
|
e.currentTarget.classList.remove('drag-over');
|
|
3696
3812
|
const newStatus = e.currentTarget.dataset.status;
|
|
@@ -3720,6 +3836,513 @@ async function onColumnDrop(e) {
|
|
|
3720
3836
|
|
|
3721
3837
|
//#endregion
|
|
3722
3838
|
|
|
3839
|
+
//#region SESSION_GROUPS
|
|
3840
|
+
// User-named groups that hold whole projects and individual sessions, dragged in from the
|
|
3841
|
+
// sidebar. They render above the project groups. Membership is a workspace preference kept in
|
|
3842
|
+
// localStorage; an id missing from `sessions` is NOT proof the session is gone (sessionLimit
|
|
3843
|
+
// windows the list), so membership is never garbage-collected on load.
|
|
3844
|
+
const SESSION_GROUPS_KEY = 'sessionGroups';
|
|
3845
|
+
const SG_ACTION_SELECTOR =
|
|
3846
|
+
'.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';
|
|
3847
|
+
|
|
3848
|
+
// Collapse state shares the existing `collapsedGroups` key; this namespace keeps it from
|
|
3849
|
+
// colliding with a project path or the __ungrouped__ / __pinned_* sentinels.
|
|
3850
|
+
function groupChevronSvg(size = 12) {
|
|
3851
|
+
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>`;
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
function sgKey(id) {
|
|
3855
|
+
return `__group_${id}__`;
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3858
|
+
function loadSessionGroups() {
|
|
3859
|
+
try {
|
|
3860
|
+
const list = JSON.parse(localStorage.getItem(SESSION_GROUPS_KEY) || 'null')?.groups;
|
|
3861
|
+
sessionGroups = (Array.isArray(list) ? list : [])
|
|
3862
|
+
.filter((g) => g && typeof g.id === 'string')
|
|
3863
|
+
.map((g) => ({
|
|
3864
|
+
id: g.id,
|
|
3865
|
+
name: typeof g.name === 'string' && g.name.trim() ? g.name : 'Group',
|
|
3866
|
+
members: (Array.isArray(g.members) ? g.members : [])
|
|
3867
|
+
.filter((m) => m && (m.type === 'project' || m.type === 'session') && typeof m.ref === 'string')
|
|
3868
|
+
.map((m) => {
|
|
3869
|
+
const out = { type: m.type, ref: m.ref };
|
|
3870
|
+
if (typeof m.under === 'string' && m.under) out.under = m.under;
|
|
3871
|
+
if (m.loose === true) out.loose = true;
|
|
3872
|
+
return out;
|
|
3873
|
+
}),
|
|
3874
|
+
}));
|
|
3875
|
+
} catch (_) {
|
|
3876
|
+
sessionGroups = [];
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3880
|
+
function persistSessionGroups() {
|
|
3881
|
+
try {
|
|
3882
|
+
localStorage.setItem(SESSION_GROUPS_KEY, JSON.stringify({ version: 1, groups: sessionGroups }));
|
|
3883
|
+
} catch (_) {}
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
function sgGroupById(id) {
|
|
3887
|
+
return sessionGroups.find((g) => g.id === id) || null;
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
function sgGroupOf(type, ref) {
|
|
3891
|
+
return sessionGroups.find((g) => g.members.some((m) => m.type === type && m.ref === ref)) || null;
|
|
3892
|
+
}
|
|
3893
|
+
|
|
3894
|
+
// An individually placed session wins over the placement of its project.
|
|
3895
|
+
function sgGroupForSession(session) {
|
|
3896
|
+
return sgGroupOf('session', session.id) || (session.project ? sgGroupOf('project', session.project) : null);
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3899
|
+
function sgDetach(type, ref) {
|
|
3900
|
+
for (const g of sessionGroups) {
|
|
3901
|
+
g.members = g.members.filter((m) => !(m.type === type && m.ref === ref));
|
|
3902
|
+
}
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
// Where a session renders inside a group: the project block it was moved under, its own
|
|
3906
|
+
// project's block, or - when the user lifted it out - the group's own loose list.
|
|
3907
|
+
function sgHostOf(group, session) {
|
|
3908
|
+
if (!group || !session) return null;
|
|
3909
|
+
const m = group.members.find((x) => x.type === 'session' && x.ref === session.id);
|
|
3910
|
+
const isMemberProject = (path) => !!path && group.members.some((x) => x.type === 'project' && x.ref === path);
|
|
3911
|
+
if (m?.under && isMemberProject(m.under)) return m.under;
|
|
3912
|
+
if (!m?.loose && isMemberProject(session.project)) return session.project;
|
|
3913
|
+
return null;
|
|
3914
|
+
}
|
|
3915
|
+
// `opts.under` places the session under another project block of the group; `opts.loose` lifts it
|
|
3916
|
+
// out of its project block onto the group's own level.
|
|
3917
|
+
function sgAssign(groupId, type, ref, opts = {}) {
|
|
3918
|
+
const group = sgGroupById(groupId);
|
|
3919
|
+
if (!group) return;
|
|
3920
|
+
sgDetach(type, ref);
|
|
3921
|
+
// Pulling a whole project in supersedes the individual placements of its sessions.
|
|
3922
|
+
if (type === 'project') {
|
|
3923
|
+
for (const s of sessions) if (s.project === ref) sgDetach('session', s.id);
|
|
3924
|
+
}
|
|
3925
|
+
const own = type === 'session' ? sessions.find((s) => s.id === ref)?.project : null;
|
|
3926
|
+
// Under its own project block is simply the natural placement - no override needed.
|
|
3927
|
+
const under = opts.under && opts.under !== own ? opts.under : null;
|
|
3928
|
+
const loose = !under && !!opts.loose;
|
|
3929
|
+
const covered =
|
|
3930
|
+
type === 'session' && !under && !loose && group.members.some((m) => m.type === 'project' && m.ref === own);
|
|
3931
|
+
// The group already holds the session's project — a separate placement would only strand
|
|
3932
|
+
// the card at the bottom of the group, away from its project block.
|
|
3933
|
+
if (!covered) {
|
|
3934
|
+
const member = { type, ref };
|
|
3935
|
+
if (under) member.under = under;
|
|
3936
|
+
if (loose) member.loose = true;
|
|
3937
|
+
group.members.push(member);
|
|
3938
|
+
}
|
|
3939
|
+
if (collapsedProjectGroups.delete(sgKey(groupId))) persistCollapsedGroups();
|
|
3940
|
+
persistSessionGroups();
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
function sgCreateGroup(name) {
|
|
3944
|
+
const group = {
|
|
3945
|
+
id: `g_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
|
3946
|
+
name: (name || '').trim() || `Group ${sessionGroups.length + 1}`,
|
|
3947
|
+
members: [],
|
|
3948
|
+
};
|
|
3949
|
+
sessionGroups.push(group);
|
|
3950
|
+
persistSessionGroups();
|
|
3951
|
+
return group;
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3954
|
+
function sgDeleteGroup(id) {
|
|
3955
|
+
sessionGroups = sessionGroups.filter((g) => g.id !== id);
|
|
3956
|
+
if (collapsedProjectGroups.delete(sgKey(id))) persistCollapsedGroups();
|
|
3957
|
+
persistSessionGroups();
|
|
3958
|
+
}
|
|
3959
|
+
|
|
3960
|
+
// Move one member of a group in front of another — the user's own order inside the group.
|
|
3961
|
+
function sgReorderMember(groupId, drag, target) {
|
|
3962
|
+
const group = sgGroupById(groupId);
|
|
3963
|
+
if (!group) return;
|
|
3964
|
+
const from = group.members.findIndex((m) => m.type === drag.kind && m.ref === drag.ref);
|
|
3965
|
+
const to = group.members.findIndex((m) => m.type === target.type && m.ref === target.ref);
|
|
3966
|
+
if (from < 0 || to < 0 || from === to) return;
|
|
3967
|
+
const [member] = group.members.splice(from, 1);
|
|
3968
|
+
group.members.splice(to, 0, member);
|
|
3969
|
+
persistSessionGroups();
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3972
|
+
function sgReorderGroup(dragId, targetId) {
|
|
3973
|
+
const from = sessionGroups.findIndex((g) => g.id === dragId);
|
|
3974
|
+
const to = sessionGroups.findIndex((g) => g.id === targetId);
|
|
3975
|
+
if (from < 0 || to < 0 || from === to) return;
|
|
3976
|
+
const [g] = sessionGroups.splice(from, 1);
|
|
3977
|
+
sessionGroups.splice(to, 0, g);
|
|
3978
|
+
persistSessionGroups();
|
|
3979
|
+
}
|
|
3980
|
+
|
|
3981
|
+
// Typing a group name should surface the sessions inside it.
|
|
3982
|
+
function sgSearchMatchIds(query) {
|
|
3983
|
+
const ids = new Set();
|
|
3984
|
+
if (!query) return ids;
|
|
3985
|
+
const hits = sessionGroups.filter((g) => fuzzyMatch(g.name, query));
|
|
3986
|
+
if (hits.length === 0) return ids;
|
|
3987
|
+
const hitIds = new Set(hits.map((g) => g.id));
|
|
3988
|
+
for (const s of sessions) {
|
|
3989
|
+
const group = sgGroupForSession(s);
|
|
3990
|
+
if (group && hitIds.has(group.id)) ids.add(s.id);
|
|
3991
|
+
}
|
|
3992
|
+
return ids;
|
|
3993
|
+
}
|
|
3994
|
+
|
|
3995
|
+
function sgHeaderHtml(group, countHtml) {
|
|
3996
|
+
const collapsed = collapsedProjectGroups.has(sgKey(group.id));
|
|
3997
|
+
const editing = sgEditingId === group.id;
|
|
3998
|
+
const nameHtml = editing
|
|
3999
|
+
? `<input class="sg-name-input" type="text" value="${escapeHtml(group.name)}" aria-label="Group name" spellcheck="false">`
|
|
4000
|
+
: `<span class="group-name">${escapeHtml(group.name)}</span>`;
|
|
4001
|
+
return `
|
|
4002
|
+
<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">
|
|
4003
|
+
${groupChevronSvg()}
|
|
4004
|
+
${nameHtml}
|
|
4005
|
+
${countHtml}
|
|
4006
|
+
<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>
|
|
4007
|
+
<span class="sg-action sg-delete" title="Delete group (members return to Projects)">×</span>
|
|
4008
|
+
</div>`;
|
|
4009
|
+
}
|
|
4010
|
+
|
|
4011
|
+
//#region SESSION_GROUPS_RENAME
|
|
4012
|
+
// The name is edited in place. While the field is live, renderSessions() defers (an SSE
|
|
4013
|
+
// refresh would otherwise replace the input mid-keystroke) — see the guard in renderSessions.
|
|
4014
|
+
let sgEditingId = null;
|
|
4015
|
+
|
|
4016
|
+
function sgIsEditing() {
|
|
4017
|
+
return !!sgEditingId && !!sessionsList.querySelector('.sg-name-input');
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
function sgBeginRename(id) {
|
|
4021
|
+
sgEditingId = id;
|
|
4022
|
+
renderSessions();
|
|
4023
|
+
const input = sessionsList.querySelector('.sg-name-input');
|
|
4024
|
+
if (input) input.select();
|
|
4025
|
+
else sgEditingId = null;
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
function sgCommitRename(input, save) {
|
|
4029
|
+
const group = sgGroupById(sgEditingId);
|
|
4030
|
+
const value = input.value.trim();
|
|
4031
|
+
sgEditingId = null;
|
|
4032
|
+
if (save && group && value && value !== group.name) {
|
|
4033
|
+
group.name = value;
|
|
4034
|
+
persistSessionGroups();
|
|
4035
|
+
}
|
|
4036
|
+
renderSessions();
|
|
4037
|
+
}
|
|
4038
|
+
//#endregion
|
|
4039
|
+
|
|
4040
|
+
//#region SESSION_GROUPS_DND
|
|
4041
|
+
// At most one zone is lit, so the reference is enough - dragover fires many times a second.
|
|
4042
|
+
let sgLitZone = null;
|
|
4043
|
+
|
|
4044
|
+
function sgClearDropTargets() {
|
|
4045
|
+
sgLitZone?.classList.remove('sg-drop-over');
|
|
4046
|
+
sgLitZone = null;
|
|
4047
|
+
}
|
|
4048
|
+
|
|
4049
|
+
function sgLightDropTarget(zone) {
|
|
4050
|
+
if (sgLitZone === zone) return;
|
|
4051
|
+
sgClearDropTargets();
|
|
4052
|
+
zone.classList.add('sg-drop-over');
|
|
4053
|
+
sgLitZone = zone;
|
|
4054
|
+
}
|
|
4055
|
+
|
|
4056
|
+
function sgDragPayload(el) {
|
|
4057
|
+
if (el.classList.contains('session-group-header')) return { kind: 'group', ref: el.dataset.groupId };
|
|
4058
|
+
// data-project-path is on real project headers only, so the sentinels drop out by themselves.
|
|
4059
|
+
if (el.classList.contains('project-group-header')) {
|
|
4060
|
+
return el.dataset.projectPath ? { kind: 'project', ref: el.dataset.projectPath } : null;
|
|
4061
|
+
}
|
|
4062
|
+
if (el.classList.contains('session-item')) return { kind: 'session', ref: el.dataset.sessionId };
|
|
4063
|
+
return null;
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
// Resolve the element under the pointer to a legal drop zone for the in-flight drag.
|
|
4067
|
+
// Returns {zone} to ungroup, {zone, groupId} to assign, or {zone, pairWith}/{zone, pairSession}
|
|
4068
|
+
// to stack two ungrouped items into a brand-new group.
|
|
4069
|
+
function sgDropZone(target) {
|
|
4070
|
+
if (!sgDrag) return null;
|
|
4071
|
+
const base =
|
|
4072
|
+
'.sg-ungroup-zone, .session-group-header, .session-group-sessions, .project-group-header, .project-group-sessions';
|
|
4073
|
+
// A dragged project pairs with the project block it lands in, not with the card under the
|
|
4074
|
+
// pointer; a dragged session can stack onto another session.
|
|
4075
|
+
const zone = target.closest(sgDrag.kind === 'session' ? `.session-item, ${base}` : base);
|
|
4076
|
+
if (!zone) return null;
|
|
4077
|
+
const dragGroup = sgGroupOf(sgDrag.kind, sgDrag.ref);
|
|
4078
|
+
if (sgDrag.kind === 'group') {
|
|
4079
|
+
// A group only reorders against another group header.
|
|
4080
|
+
return zone.classList.contains('session-group-header') && zone.dataset.groupId !== sgDrag.ref
|
|
4081
|
+
? { zone, groupId: zone.dataset.groupId }
|
|
4082
|
+
: null;
|
|
4083
|
+
}
|
|
4084
|
+
if (zone.classList.contains('sg-ungroup-zone')) return dragGroup ? { zone } : null;
|
|
4085
|
+
const dragSession = sgDrag.kind === 'session' ? sessions.find((x) => x.id === sgDrag.ref) : null;
|
|
4086
|
+
const fromGroup = dragSession ? sgGroupForSession(dragSession) : dragGroup;
|
|
4087
|
+
const fromGroupId = fromGroup?.id || null;
|
|
4088
|
+
// A drop that lands on the host the session already has changes nothing, and highlighting it
|
|
4089
|
+
// promises a move that never happens.
|
|
4090
|
+
const fromHost = dragSession ? sgHostOf(fromGroup, dragSession) : null;
|
|
4091
|
+
const groupId = zone.dataset.groupId;
|
|
4092
|
+
if (groupId) {
|
|
4093
|
+
if (!dragSession) return dragGroup?.id === groupId ? null : { zone, groupId };
|
|
4094
|
+
if (fromGroupId !== groupId) return { zone, groupId };
|
|
4095
|
+
// Already in this group: its own level is a move only out of a project block.
|
|
4096
|
+
return fromHost ? { zone, groupId, loose: true } : null;
|
|
4097
|
+
}
|
|
4098
|
+
const targetSessionId = zone.dataset.sessionId;
|
|
4099
|
+
if (targetSessionId) {
|
|
4100
|
+
if (targetSessionId === sgDrag.ref) return null;
|
|
4101
|
+
const targetSession = sessions.find((s) => s.id === targetSessionId);
|
|
4102
|
+
const targetGroup = targetSession ? sgGroupForSession(targetSession) : null;
|
|
4103
|
+
const targetGroupId = targetGroup?.id || null;
|
|
4104
|
+
if (targetGroup) {
|
|
4105
|
+
// Stacking onto a card adopts that card's placement, project block included.
|
|
4106
|
+
const targetHost = sgHostOf(targetGroup, targetSession);
|
|
4107
|
+
if (targetHost) {
|
|
4108
|
+
return targetHost === fromHost && fromGroupId === targetGroupId
|
|
4109
|
+
? null
|
|
4110
|
+
: { zone, groupId: targetGroupId, under: targetHost };
|
|
4111
|
+
}
|
|
4112
|
+
// Both loose in the same group: this is a reorder, and only a member has a slot.
|
|
4113
|
+
if (fromGroupId === targetGroupId && !fromHost) {
|
|
4114
|
+
return sgGroupOf('session', targetSessionId)?.id === targetGroupId
|
|
4115
|
+
? { zone, groupId: targetGroupId, reorder: { type: 'session', ref: targetSessionId } }
|
|
4116
|
+
: null;
|
|
4117
|
+
}
|
|
4118
|
+
return { zone, groupId: targetGroupId, loose: true };
|
|
4119
|
+
}
|
|
4120
|
+
return { zone, pairSession: targetSessionId };
|
|
4121
|
+
}
|
|
4122
|
+
const path = zone.dataset.projectPath;
|
|
4123
|
+
if (!path || path === sgDrag.ref) return null;
|
|
4124
|
+
// Dropping onto a project that already sits in a group means "join that group".
|
|
4125
|
+
const pathGroup = sgGroupOf('project', path);
|
|
4126
|
+
if (pathGroup) {
|
|
4127
|
+
if (!dragSession) {
|
|
4128
|
+
return dragGroup?.id === pathGroup.id
|
|
4129
|
+
? { zone, groupId: pathGroup.id, reorder: { type: 'project', ref: path } }
|
|
4130
|
+
: { zone, groupId: pathGroup.id };
|
|
4131
|
+
}
|
|
4132
|
+
return fromHost === path && fromGroupId === pathGroup.id ? null : { zone, groupId: pathGroup.id, under: path };
|
|
4133
|
+
}
|
|
4134
|
+
// Stacking a session onto its own project block would only fold it back where it sits today.
|
|
4135
|
+
if (dragSession?.project === path) return null;
|
|
4136
|
+
return { zone, pairWith: path };
|
|
4137
|
+
}
|
|
4138
|
+
|
|
4139
|
+
function sgOnDragStart(e) {
|
|
4140
|
+
const el = e.target.closest('.session-group-header, .project-group-header, .session-item');
|
|
4141
|
+
if (!el || e.target.closest(SG_ACTION_SELECTOR)) {
|
|
4142
|
+
e.preventDefault();
|
|
4143
|
+
return;
|
|
4144
|
+
}
|
|
4145
|
+
const payload = sgDragPayload(el);
|
|
4146
|
+
if (!payload?.ref) {
|
|
4147
|
+
e.preventDefault();
|
|
4148
|
+
return;
|
|
4149
|
+
}
|
|
4150
|
+
sgDrag = payload;
|
|
4151
|
+
el.classList.add('sg-dragging');
|
|
4152
|
+
sessionsList.classList.add('sg-dragging-active');
|
|
4153
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
4154
|
+
try {
|
|
4155
|
+
e.dataTransfer.setData('text/plain', JSON.stringify(payload));
|
|
4156
|
+
} catch (_) {}
|
|
4157
|
+
}
|
|
4158
|
+
|
|
4159
|
+
function sgOnDragOver(e) {
|
|
4160
|
+
const hit = sgDropZone(e.target);
|
|
4161
|
+
// Moving onto a spot that refuses the drop has to put the previous highlight out, or two zones
|
|
4162
|
+
// stay lit and the one under the pointer is not the one that would receive the drop.
|
|
4163
|
+
if (!hit) {
|
|
4164
|
+
sgClearDropTargets();
|
|
4165
|
+
return;
|
|
4166
|
+
}
|
|
4167
|
+
e.preventDefault();
|
|
4168
|
+
e.dataTransfer.dropEffect = 'move';
|
|
4169
|
+
sgLightDropTarget(hit.zone);
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
function sgOnDrop(e) {
|
|
4173
|
+
const hit = sgDropZone(e.target);
|
|
4174
|
+
if (!hit) return;
|
|
4175
|
+
e.preventDefault();
|
|
4176
|
+
e.stopPropagation();
|
|
4177
|
+
const drag = sgDrag;
|
|
4178
|
+
if (hit.pairWith || hit.pairSession) {
|
|
4179
|
+
// Stacked onto an ungrouped item: seed a group, then let the user name it in place.
|
|
4180
|
+
const target = hit.pairWith
|
|
4181
|
+
? { type: 'project', ref: hit.pairWith, path: hit.pairWith }
|
|
4182
|
+
: { type: 'session', ref: hit.pairSession, path: sessions.find((s) => s.id === hit.pairSession)?.project };
|
|
4183
|
+
const group = sgCreateGroup(target.path ? target.path.split(/[/\\]/).pop() : '');
|
|
4184
|
+
sgAssign(group.id, target.type, target.ref);
|
|
4185
|
+
sgAssign(group.id, drag.kind, drag.ref);
|
|
4186
|
+
sgFinishDrag();
|
|
4187
|
+
sgBeginRename(group.id);
|
|
4188
|
+
return;
|
|
4189
|
+
}
|
|
4190
|
+
if (hit.groupId) {
|
|
4191
|
+
if (hit.reorder) sgReorderMember(hit.groupId, drag, hit.reorder);
|
|
4192
|
+
else if (drag.kind === 'group') sgReorderGroup(drag.ref, hit.groupId);
|
|
4193
|
+
else sgAssign(hit.groupId, drag.kind, drag.ref, { under: hit.under, loose: hit.loose });
|
|
4194
|
+
} else {
|
|
4195
|
+
sgDetach(drag.kind, drag.ref);
|
|
4196
|
+
persistSessionGroups();
|
|
4197
|
+
}
|
|
4198
|
+
sgFinishDrag();
|
|
4199
|
+
}
|
|
4200
|
+
|
|
4201
|
+
function sgFinishDrag() {
|
|
4202
|
+
sgDrag = null;
|
|
4203
|
+
sgClearDropTargets();
|
|
4204
|
+
sessionsList.classList.remove('sg-dragging-active');
|
|
4205
|
+
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
4206
|
+
document.querySelectorAll('.sg-dragging').forEach((el) => el.classList.remove('sg-dragging'));
|
|
4207
|
+
renderSessions();
|
|
4208
|
+
}
|
|
4209
|
+
|
|
4210
|
+
function sgOnDragEnd() {
|
|
4211
|
+
if (!sgDrag) {
|
|
4212
|
+
sgClearDropTargets();
|
|
4213
|
+
sessionsList.classList.remove('sg-dragging-active');
|
|
4214
|
+
return;
|
|
4215
|
+
}
|
|
4216
|
+
sgFinishDrag();
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
function initSessionGroupsDnd() {
|
|
4220
|
+
sessionsList.addEventListener('dragstart', sgOnDragStart);
|
|
4221
|
+
sessionsList.addEventListener('dragover', sgOnDragOver);
|
|
4222
|
+
sessionsList.addEventListener('dragleave', (e) => {
|
|
4223
|
+
const zone = e.target.closest?.('.sg-drop-over');
|
|
4224
|
+
if (zone && !zone.contains(e.relatedTarget)) sgClearDropTargets();
|
|
4225
|
+
});
|
|
4226
|
+
sessionsList.addEventListener('drop', sgOnDrop);
|
|
4227
|
+
sessionsList.addEventListener('dragend', sgOnDragEnd);
|
|
4228
|
+
sessionsList.addEventListener('contextmenu', sgOnContextMenu);
|
|
4229
|
+
sessionsList.addEventListener('keydown', (e) => {
|
|
4230
|
+
const input = e.target.closest?.('.sg-name-input');
|
|
4231
|
+
if (!input) return;
|
|
4232
|
+
// Keep the sidebar's own single-key shortcuts out of the field.
|
|
4233
|
+
e.stopPropagation();
|
|
4234
|
+
if (e.key === 'Enter') {
|
|
4235
|
+
e.preventDefault();
|
|
4236
|
+
sgCommitRename(input, true);
|
|
4237
|
+
} else if (e.key === 'Escape') {
|
|
4238
|
+
e.preventDefault();
|
|
4239
|
+
sgCommitRename(input, false);
|
|
4240
|
+
}
|
|
4241
|
+
});
|
|
4242
|
+
sessionsList.addEventListener('focusout', (e) => {
|
|
4243
|
+
const input = e.target.closest?.('.sg-name-input');
|
|
4244
|
+
if (input && sgEditingId) sgCommitRename(input, true);
|
|
4245
|
+
});
|
|
4246
|
+
// HTML5 drag-and-drop never fires on touch, and cck ships as a PWA.
|
|
4247
|
+
sessionsList.addEventListener('touchstart', sgOnTouchStart, { passive: true });
|
|
4248
|
+
sessionsList.addEventListener('touchend', sgCancelLongPress);
|
|
4249
|
+
sessionsList.addEventListener('touchmove', sgCancelLongPress, { passive: true });
|
|
4250
|
+
window.addEventListener('storage', (e) => {
|
|
4251
|
+
if (e.key !== SESSION_GROUPS_KEY) return;
|
|
4252
|
+
loadSessionGroups();
|
|
4253
|
+
renderSessions();
|
|
4254
|
+
});
|
|
4255
|
+
}
|
|
4256
|
+
//#endregion
|
|
4257
|
+
|
|
4258
|
+
//#region SESSION_GROUPS_MENU
|
|
4259
|
+
// Non-drag path: right-click or long-press a session / project header.
|
|
4260
|
+
let sgLongPressTimer = null;
|
|
4261
|
+
|
|
4262
|
+
function sgCloseMenu() {
|
|
4263
|
+
const menu = document.getElementById('sg-menu');
|
|
4264
|
+
if (menu) menu.remove();
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
function sgOpenMenu(x, y, kind, ref) {
|
|
4268
|
+
sgCloseMenu();
|
|
4269
|
+
const current = sgGroupOf(kind, ref);
|
|
4270
|
+
const label = kind === 'project' ? 'project' : 'session';
|
|
4271
|
+
const rows = sessionGroups
|
|
4272
|
+
.filter((g) => g.id !== current?.id)
|
|
4273
|
+
.map((g) => `<button class="sg-menu-item" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(g.name)}</button>`)
|
|
4274
|
+
.join('');
|
|
4275
|
+
const menu = document.createElement('div');
|
|
4276
|
+
menu.id = 'sg-menu';
|
|
4277
|
+
menu.className = 'sg-menu';
|
|
4278
|
+
menu.dataset.sgKind = kind;
|
|
4279
|
+
menu.dataset.sgRef = ref;
|
|
4280
|
+
menu.innerHTML = `
|
|
4281
|
+
<div class="sg-menu-label">Move ${label} to group</div>
|
|
4282
|
+
${rows}
|
|
4283
|
+
<button class="sg-menu-item" data-sg-move="__new__">+ New group…</button>
|
|
4284
|
+
${current ? `<button class="sg-menu-item sg-menu-remove" data-sg-move="__none__">Remove from “${escapeHtml(current.name)}”</button>` : ''}`;
|
|
4285
|
+
document.body.appendChild(menu);
|
|
4286
|
+
const rect = menu.getBoundingClientRect();
|
|
4287
|
+
menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`;
|
|
4288
|
+
menu.style.top = `${Math.min(y, window.innerHeight - rect.height - 8)}px`;
|
|
4289
|
+
}
|
|
4290
|
+
|
|
4291
|
+
function sgOnContextMenu(e) {
|
|
4292
|
+
const el = e.target.closest('.session-item, .project-group-header');
|
|
4293
|
+
if (!el) return;
|
|
4294
|
+
const payload = sgDragPayload(el);
|
|
4295
|
+
if (!payload?.ref) return;
|
|
4296
|
+
e.preventDefault();
|
|
4297
|
+
sgOpenMenu(e.clientX, e.clientY, payload.kind, payload.ref);
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4300
|
+
function sgCancelLongPress() {
|
|
4301
|
+
if (sgLongPressTimer) clearTimeout(sgLongPressTimer);
|
|
4302
|
+
sgLongPressTimer = null;
|
|
4303
|
+
}
|
|
4304
|
+
|
|
4305
|
+
function sgOnTouchStart(e) {
|
|
4306
|
+
const el = e.target.closest('.session-item, .project-group-header');
|
|
4307
|
+
if (!el || e.target.closest(SG_ACTION_SELECTOR)) return;
|
|
4308
|
+
const payload = sgDragPayload(el);
|
|
4309
|
+
if (!payload?.ref) return;
|
|
4310
|
+
const touch = e.touches[0];
|
|
4311
|
+
sgCancelLongPress();
|
|
4312
|
+
sgLongPressTimer = setTimeout(() => {
|
|
4313
|
+
sgLongPressTimer = null;
|
|
4314
|
+
sgOpenMenu(touch.clientX, touch.clientY, payload.kind, payload.ref);
|
|
4315
|
+
}, 550);
|
|
4316
|
+
}
|
|
4317
|
+
|
|
4318
|
+
document.addEventListener('click', (e) => {
|
|
4319
|
+
const item = e.target.closest('.sg-menu-item');
|
|
4320
|
+
if (!item) {
|
|
4321
|
+
if (!e.target.closest('.sg-menu')) sgCloseMenu();
|
|
4322
|
+
return;
|
|
4323
|
+
}
|
|
4324
|
+
e.stopPropagation();
|
|
4325
|
+
const menu = item.closest('.sg-menu');
|
|
4326
|
+
const { sgKind, sgRef } = menu.dataset;
|
|
4327
|
+
const move = item.dataset.sgMove;
|
|
4328
|
+
sgCloseMenu();
|
|
4329
|
+
if (move === '__none__') {
|
|
4330
|
+
sgDetach(sgKind, sgRef);
|
|
4331
|
+
persistSessionGroups();
|
|
4332
|
+
} else if (move === '__new__') {
|
|
4333
|
+
// Name it in place rather than through a modal prompt.
|
|
4334
|
+
const group = sgCreateGroup(sgKind === 'project' ? sgRef.split(/[/\\]/).pop() : '');
|
|
4335
|
+
sgAssign(group.id, sgKind, sgRef);
|
|
4336
|
+
sgBeginRename(group.id);
|
|
4337
|
+
return;
|
|
4338
|
+
} else {
|
|
4339
|
+
sgAssign(move, sgKind, sgRef);
|
|
4340
|
+
}
|
|
4341
|
+
renderSessions();
|
|
4342
|
+
});
|
|
4343
|
+
//#endregion
|
|
4344
|
+
//#endregion
|
|
4345
|
+
|
|
3723
4346
|
//#region KEYBOARD_NAV
|
|
3724
4347
|
function selectTask(taskId, sessionId) {
|
|
3725
4348
|
clearTaskSelection();
|
|
@@ -3794,7 +4417,11 @@ function getKbId(el) {
|
|
|
3794
4417
|
}
|
|
3795
4418
|
|
|
3796
4419
|
function getGroupSessionsContainer(header) {
|
|
3797
|
-
const cls = header.classList.contains('pinned-sub-header')
|
|
4420
|
+
const cls = header.classList.contains('pinned-sub-header')
|
|
4421
|
+
? 'pinned-sub-items'
|
|
4422
|
+
: header.classList.contains('session-group-header')
|
|
4423
|
+
? 'session-group-sessions'
|
|
4424
|
+
: 'project-group-sessions';
|
|
3798
4425
|
let el = header.nextElementSibling;
|
|
3799
4426
|
while (el && !el.classList.contains(cls)) el = el.nextElementSibling;
|
|
3800
4427
|
return el;
|
|
@@ -3802,6 +4429,7 @@ function getGroupSessionsContainer(header) {
|
|
|
3802
4429
|
|
|
3803
4430
|
function getNavigableItems() {
|
|
3804
4431
|
const items = [];
|
|
4432
|
+
// A named group nests project blocks inside its body, so the walk has to recurse.
|
|
3805
4433
|
const walkGroupContainer = (container) => {
|
|
3806
4434
|
if (!container) return;
|
|
3807
4435
|
for (const child of container.children) {
|
|
@@ -3814,11 +4442,16 @@ function getNavigableItems() {
|
|
|
3814
4442
|
}
|
|
3815
4443
|
} else if (child.classList.contains('session-item')) {
|
|
3816
4444
|
items.push(child);
|
|
4445
|
+
} else if (child.classList.contains('project-group-header')) {
|
|
4446
|
+
items.push(child);
|
|
4447
|
+
if (!collapsedProjectGroups.has(child.dataset.groupPath)) {
|
|
4448
|
+
walkGroupContainer(getGroupSessionsContainer(child));
|
|
4449
|
+
}
|
|
3817
4450
|
}
|
|
3818
4451
|
}
|
|
3819
4452
|
};
|
|
3820
4453
|
for (const el of sessionsList.children) {
|
|
3821
|
-
if (el.classList.contains('project-group-header')) {
|
|
4454
|
+
if (el.classList.contains('project-group-header') || el.classList.contains('session-group-header')) {
|
|
3822
4455
|
items.push(el);
|
|
3823
4456
|
if (!collapsedProjectGroups.has(el.dataset.groupPath)) {
|
|
3824
4457
|
walkGroupContainer(getGroupSessionsContainer(el));
|
|
@@ -3907,6 +4540,9 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
3907
4540
|
activeIds.add(s.id);
|
|
3908
4541
|
if (onlyNew && (!primed || prevActiveSessionIds.has(s.id))) continue;
|
|
3909
4542
|
if (collapsedProjectGroups.delete(s.project || '__ungrouped__')) changed = true;
|
|
4543
|
+
// A named group wrapping that project (or the session itself) would keep it hidden.
|
|
4544
|
+
const group = sgGroupForSession(s);
|
|
4545
|
+
if (group && collapsedProjectGroups.delete(sgKey(group.id))) changed = true;
|
|
3910
4546
|
}
|
|
3911
4547
|
prevActiveSessionIds = activeIds;
|
|
3912
4548
|
if (changed) persistCollapsedGroups();
|
|
@@ -3914,7 +4550,11 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
3914
4550
|
}
|
|
3915
4551
|
|
|
3916
4552
|
function isGroupHeader(el) {
|
|
3917
|
-
return
|
|
4553
|
+
return (
|
|
4554
|
+
el.classList.contains('project-group-header') ||
|
|
4555
|
+
el.classList.contains('pinned-sub-header') ||
|
|
4556
|
+
el.classList.contains('session-group-header')
|
|
4557
|
+
);
|
|
3918
4558
|
}
|
|
3919
4559
|
|
|
3920
4560
|
function findParentHeader(el) {
|
|
@@ -3922,10 +4562,10 @@ function findParentHeader(el) {
|
|
|
3922
4562
|
if (subContainer?.previousElementSibling?.classList.contains('pinned-sub-header')) {
|
|
3923
4563
|
return subContainer.previousElementSibling;
|
|
3924
4564
|
}
|
|
3925
|
-
const container = el.closest('.project-group-sessions');
|
|
4565
|
+
const container = el.closest('.project-group-sessions, .session-group-sessions');
|
|
3926
4566
|
if (!container) return null;
|
|
3927
4567
|
let header = container.previousElementSibling;
|
|
3928
|
-
while (header && !
|
|
4568
|
+
while (header && !isGroupHeader(header)) header = header.previousElementSibling;
|
|
3929
4569
|
return header;
|
|
3930
4570
|
}
|
|
3931
4571
|
|
|
@@ -3939,6 +4579,12 @@ function handleSidebarHorizontal(direction) {
|
|
|
3939
4579
|
const isCollapsed = collapsedProjectGroups.has(el.dataset.groupPath);
|
|
3940
4580
|
if (collapse) {
|
|
3941
4581
|
if (!isCollapsed) setGroupCollapsed(el, true);
|
|
4582
|
+
else {
|
|
4583
|
+
// Already closed — step out to the named group that holds this block, if any.
|
|
4584
|
+
const parent = findParentHeader(el);
|
|
4585
|
+
const parentIdx = parent ? items.indexOf(parent) : -1;
|
|
4586
|
+
if (parentIdx >= 0) selectSessionByIndex(parentIdx, items);
|
|
4587
|
+
}
|
|
3942
4588
|
} else if (isCollapsed) {
|
|
3943
4589
|
setGroupCollapsed(el, false);
|
|
3944
4590
|
} else {
|
|
@@ -3971,8 +4617,6 @@ function activateSelectedSession(items) {
|
|
|
3971
4617
|
|
|
3972
4618
|
function setFocusZone(zone) {
|
|
3973
4619
|
const sidebar = document.querySelector('.sidebar');
|
|
3974
|
-
// Clear all zone visuals
|
|
3975
|
-
sidebar.classList.remove('sidebar-focused');
|
|
3976
4620
|
clearKbSelection();
|
|
3977
4621
|
clearTaskSelection();
|
|
3978
4622
|
|
|
@@ -3982,7 +4626,6 @@ function setFocusZone(zone) {
|
|
|
3982
4626
|
sidebar.classList.remove('collapsed');
|
|
3983
4627
|
localStorage.setItem('sidebar-collapsed', false);
|
|
3984
4628
|
}
|
|
3985
|
-
sidebar.classList.add('sidebar-focused');
|
|
3986
4629
|
const items = getNavigableItems();
|
|
3987
4630
|
if (items.length > 0) {
|
|
3988
4631
|
const activeIdx = items.findIndex((el) => el.classList.contains('active'));
|
|
@@ -4836,13 +5479,14 @@ function _renderStorageLinkedDocs() {
|
|
|
4836
5479
|
const { groups, orphans } = _groupByProject(entries.map((e) => e.sessionId));
|
|
4837
5480
|
|
|
4838
5481
|
function renderDocRow(sessionId, p) {
|
|
4839
|
-
const name = p
|
|
5482
|
+
const name = linkedDocLabel(p);
|
|
4840
5483
|
const sid = escAttrJs(sessionId);
|
|
4841
5484
|
const jsPath = escAttrJs(p);
|
|
4842
5485
|
return `<div class="storage-item" style="padding-left:24px;">
|
|
4843
5486
|
<span class="storage-item-id" title="${escapeHtml(p)}">${escapeHtml(name)}</span>
|
|
4844
5487
|
<div class="storage-item-actions">
|
|
4845
5488
|
<button onclick="_storagePreviewLinkedDoc('${jsPath}')">View</button>
|
|
5489
|
+
<button onclick="copyWithFeedback('${jsPath}', this)" title="Copy path" aria-label="Copy path">${ICON_COPY}</button>
|
|
4846
5490
|
<button class="danger" onclick="_storageUnlinkDoc('${sid}','${jsPath}')">Unlink</button>
|
|
4847
5491
|
</div>
|
|
4848
5492
|
</div>`;
|
|
@@ -4879,7 +5523,7 @@ function _renderStorageLinkedDocs() {
|
|
|
4879
5523
|
}
|
|
4880
5524
|
|
|
4881
5525
|
function _storagePreviewLinkedDoc(path) {
|
|
4882
|
-
|
|
5526
|
+
openLinkedDoc(path);
|
|
4883
5527
|
}
|
|
4884
5528
|
|
|
4885
5529
|
function _storageUnlinkDoc(sessionId, path) {
|
|
@@ -5125,7 +5769,6 @@ document.addEventListener('keydown', (e) => {
|
|
|
5125
5769
|
}
|
|
5126
5770
|
if (e.key === 'Escape') {
|
|
5127
5771
|
// Plain unfocus — drop the keyboard cursor without jumping into the board
|
|
5128
|
-
document.querySelector('.sidebar').classList.remove('sidebar-focused');
|
|
5129
5772
|
clearKbSelection();
|
|
5130
5773
|
focusZone = 'board';
|
|
5131
5774
|
return;
|
|
@@ -5541,21 +6184,74 @@ function isPreviewablePath(p) {
|
|
|
5541
6184
|
return /\.(md|markdown|html?)$/i.test(p);
|
|
5542
6185
|
}
|
|
5543
6186
|
|
|
6187
|
+
// A scratchpad manifest opens in the external `scratch` viewer, not the editor —
|
|
6188
|
+
// the raw JSON is a manifest, not the document the user linked.
|
|
6189
|
+
function isScratchpadPath(p) {
|
|
6190
|
+
return /(^|[\\/])scratchpad\.json$/i.test(p);
|
|
6191
|
+
}
|
|
6192
|
+
|
|
6193
|
+
// `linkedDocOpener` is the only place a linked path is classified; the row's tag,
|
|
6194
|
+
// tooltip and action are all read from here, so a new destination is one entry.
|
|
6195
|
+
const LINKED_DOC_OPENERS = {
|
|
6196
|
+
preview: { tag: '', hint: '' },
|
|
6197
|
+
scratch: {
|
|
6198
|
+
tag: '(scratchpad)',
|
|
6199
|
+
hint: ' — opens in the scratch viewer',
|
|
6200
|
+
url: '/api/scratchpad/open',
|
|
6201
|
+
body: (p) => ({ path: p }),
|
|
6202
|
+
toast: 'in the scratch viewer',
|
|
6203
|
+
},
|
|
6204
|
+
editor: {
|
|
6205
|
+
tag: '(editor)',
|
|
6206
|
+
hint: ' — opens in editor',
|
|
6207
|
+
url: '/api/open-in-editor',
|
|
6208
|
+
body: (p) => ({ file: p }),
|
|
6209
|
+
toast: 'in editor',
|
|
6210
|
+
},
|
|
6211
|
+
};
|
|
6212
|
+
|
|
6213
|
+
function linkedDocOpener(p) {
|
|
6214
|
+
if (isPreviewablePath(p)) return 'preview';
|
|
6215
|
+
// Without the CLI there is nothing to launch, so the manifest falls back to the editor.
|
|
6216
|
+
if (isScratchpadPath(p) && appConfig.scratchAvailable) return 'scratch';
|
|
6217
|
+
return 'editor';
|
|
6218
|
+
}
|
|
6219
|
+
|
|
6220
|
+
// Every manifest is called scratchpad.json, so the pad's folder names it. Kept
|
|
6221
|
+
// independent of the opener: the pad is still the pad when the CLI is missing.
|
|
6222
|
+
function linkedDocLabel(p) {
|
|
6223
|
+
const parts = p.split(/[\\/]/).filter(Boolean);
|
|
6224
|
+
return isScratchpadPath(p) ? parts[parts.length - 2] || parts[parts.length - 1] : parts[parts.length - 1];
|
|
6225
|
+
}
|
|
6226
|
+
|
|
6227
|
+
function openLinkedDoc(p, baseDir) {
|
|
6228
|
+
const opener = linkedDocOpener(p);
|
|
6229
|
+
if (opener === 'preview') {
|
|
6230
|
+
openPreviewByPath(p, baseDir);
|
|
6231
|
+
return;
|
|
6232
|
+
}
|
|
6233
|
+
const { url, body, toast } = LINKED_DOC_OPENERS[opener];
|
|
6234
|
+
postAndToast(url, body(p), toast);
|
|
6235
|
+
}
|
|
6236
|
+
|
|
5544
6237
|
function renderLinkedDocsHtml(sessionId) {
|
|
5545
6238
|
const paths = getSessionPreviewPaths(sessionId);
|
|
5546
6239
|
const baseDir = getSessionBaseDir(sessionId);
|
|
5547
6240
|
const items = paths
|
|
5548
6241
|
.map((p) => {
|
|
5549
|
-
const
|
|
6242
|
+
const opener = linkedDocOpener(p);
|
|
6243
|
+
const { tag, hint } = LINKED_DOC_OPENERS[opener];
|
|
6244
|
+
const name = linkedDocLabel(p);
|
|
5550
6245
|
const rel = baseDir ? toRelativeIfUnder(p, baseDir) : null;
|
|
5551
|
-
const previewable = isPreviewablePath(p);
|
|
5552
|
-
const title = previewable ? p : `${p} — opens in editor`;
|
|
5553
6246
|
const pathSpan = rel ? `<span class="linked-doc-path" title="${escapeHtml(p)}">${escapeHtml(rel)}</span>` : '';
|
|
5554
6247
|
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
|
-
<
|
|
6248
|
+
return `<li class="linked-doc-item${opener === 'preview' ? '' : ' is-editor'}">
|
|
6249
|
+
<a href="#" class="linked-doc-link" data-path="${attr}" title="${escapeHtml(p + hint)}">${escapeHtml(name)}</a>
|
|
6250
|
+
${pathSpan}${tag ? `<span class="linked-doc-path">${tag}</span>` : ''}
|
|
6251
|
+
<span class="row-actions linked-doc-actions">
|
|
6252
|
+
<button type="button" class="linked-doc-copy" data-path="${attr}" title="Copy path" aria-label="Copy path of ${escapeHtml(name)}">${ICON_COPY}</button>
|
|
6253
|
+
<button type="button" class="linked-doc-remove" data-path="${attr}" title="Unlink" aria-label="Unlink ${escapeHtml(name)}">×</button>
|
|
6254
|
+
</span>
|
|
5559
6255
|
</li>`;
|
|
5560
6256
|
})
|
|
5561
6257
|
.join('');
|
|
@@ -5580,18 +6276,18 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
5580
6276
|
function bindLinkedDocsHandlers(container, sessionId) {
|
|
5581
6277
|
if (!container) return;
|
|
5582
6278
|
container.addEventListener('click', (e) => {
|
|
5583
|
-
const hit = e.target.closest('.linked-doc-link, .linked-doc-remove, .linked-docs-add-btn');
|
|
6279
|
+
const hit = e.target.closest('.linked-doc-link, .linked-doc-copy, .linked-doc-remove, .linked-docs-add-btn');
|
|
5584
6280
|
if (!hit) return;
|
|
5585
6281
|
e.preventDefault();
|
|
5586
6282
|
if (hit.classList.contains('linked-docs-add-btn')) {
|
|
5587
6283
|
startLinkedDocInput(container, sessionId);
|
|
6284
|
+
} else if (hit.classList.contains('linked-doc-copy')) {
|
|
6285
|
+
copyWithFeedback(hit.dataset.path, hit);
|
|
5588
6286
|
} else if (hit.classList.contains('linked-doc-remove')) {
|
|
5589
6287
|
removeSessionPreviewPath(sessionId, hit.dataset.path);
|
|
5590
6288
|
afterLinkedDocsChanged(sessionId);
|
|
5591
|
-
} else if (isPreviewablePath(hit.dataset.path)) {
|
|
5592
|
-
openPreviewByPath(hit.dataset.path, getSessionBaseDir(sessionId));
|
|
5593
6289
|
} else {
|
|
5594
|
-
|
|
6290
|
+
openLinkedDoc(hit.dataset.path, getSessionBaseDir(sessionId));
|
|
5595
6291
|
}
|
|
5596
6292
|
});
|
|
5597
6293
|
}
|
|
@@ -5599,7 +6295,7 @@ function bindLinkedDocsHandlers(container, sessionId) {
|
|
|
5599
6295
|
function startLinkedDocInput(container, sessionId) {
|
|
5600
6296
|
const slot = container.querySelector('.linked-doc-editor-slot');
|
|
5601
6297
|
if (!slot) return;
|
|
5602
|
-
slot.innerHTML = `<input class="linked-doc-input" type="text" spellcheck="false" placeholder="Absolute path, or relative to the session cwd">
|
|
6298
|
+
slot.innerHTML = `<input class="linked-doc-input" type="text" spellcheck="false" placeholder="Absolute path, file:// URL, or relative to the session cwd">
|
|
5603
6299
|
<div class="linked-doc-hint">${escapeHtml(getSessionBaseDir(sessionId) || 'no session cwd — absolute paths only')}</div>
|
|
5604
6300
|
<div class="linked-doc-error"></div>
|
|
5605
6301
|
<div class="edit-actions">
|
|
@@ -6178,9 +6874,151 @@ function formatDate(dateStr) {
|
|
|
6178
6874
|
return date.toLocaleDateString();
|
|
6179
6875
|
}
|
|
6180
6876
|
|
|
6877
|
+
// Terminal output reaches the transcript raw. A CSI-only pattern left OSC
|
|
6878
|
+
// sequences, 8-bit CSI and bare control bytes behind, and those render as tofu.
|
|
6879
|
+
// One ordered alternation covers every escape family, so both readers make a
|
|
6880
|
+
// single pass: OSC, SGR (the only one worth keeping — capture group 1 holds its
|
|
6881
|
+
// parameters), any other CSI, other two-char escapes, then whatever control
|
|
6882
|
+
// bytes are left, lone introducers included. \t \n \r are deliberately absent.
|
|
6883
|
+
const ANSI_TOKEN_RE =
|
|
6884
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: escape and control bytes are the target
|
|
6885
|
+
/\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;
|
|
6886
|
+
|
|
6181
6887
|
function stripAnsi(text) {
|
|
6182
|
-
|
|
6183
|
-
return
|
|
6888
|
+
if (typeof text !== 'string') return text;
|
|
6889
|
+
return text.replace(ANSI_TOKEN_RE, '');
|
|
6890
|
+
}
|
|
6891
|
+
|
|
6892
|
+
const ANSI_COLOR_NAMES = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
|
|
6893
|
+
const XTERM_CUBE = [0, 95, 135, 175, 215, 255];
|
|
6894
|
+
|
|
6895
|
+
// xterm-256 index -> a colour the browser understands. 0-15 stay named so they
|
|
6896
|
+
// follow the theme's palette; 16+ are exact and become an inline rgb().
|
|
6897
|
+
function ansi256Color(n) {
|
|
6898
|
+
if (n < 8) return { name: ANSI_COLOR_NAMES[n] };
|
|
6899
|
+
if (n < 16) return { name: `bright-${ANSI_COLOR_NAMES[n - 8]}` };
|
|
6900
|
+
if (n < 232) {
|
|
6901
|
+
const i = n - 16;
|
|
6902
|
+
return {
|
|
6903
|
+
rgb: `rgb(${XTERM_CUBE[Math.floor(i / 36) % 6]},${XTERM_CUBE[Math.floor(i / 6) % 6]},${XTERM_CUBE[i % 6]})`,
|
|
6904
|
+
};
|
|
6905
|
+
}
|
|
6906
|
+
const v = 8 + (n - 232) * 10;
|
|
6907
|
+
return { rgb: `rgb(${v},${v},${v})` };
|
|
6908
|
+
}
|
|
6909
|
+
|
|
6910
|
+
// Attribute codes that only flip a flag: code -> [field, value].
|
|
6911
|
+
const SGR_FLAGS = {
|
|
6912
|
+
1: ['bold', true],
|
|
6913
|
+
2: ['dim', true],
|
|
6914
|
+
3: ['italic', true],
|
|
6915
|
+
4: ['underline', true],
|
|
6916
|
+
7: ['inverse', true],
|
|
6917
|
+
9: ['strike', true],
|
|
6918
|
+
23: ['italic', false],
|
|
6919
|
+
24: ['underline', false],
|
|
6920
|
+
27: ['inverse', false],
|
|
6921
|
+
29: ['strike', false],
|
|
6922
|
+
};
|
|
6923
|
+
|
|
6924
|
+
// Reads one SGR parameter list into `st`. 38/48 consume their own arguments, so
|
|
6925
|
+
// the list is walked with an index rather than a for..of.
|
|
6926
|
+
function applySgr(params, st) {
|
|
6927
|
+
const codes = params === '' ? [0] : params.split(';').map((p) => (p === '' ? 0 : Number(p)));
|
|
6928
|
+
for (let i = 0; i < codes.length; i++) {
|
|
6929
|
+
const c = codes[i];
|
|
6930
|
+
const flag = SGR_FLAGS[c];
|
|
6931
|
+
// 30-37/90-97 are foreground, 40-47/100-107 background; the low digit is the
|
|
6932
|
+
// colour index and 9 in that slot means "back to the default".
|
|
6933
|
+
const isColor = (c >= 30 && c <= 49) || (c >= 90 && c <= 107);
|
|
6934
|
+
if (c === 0) {
|
|
6935
|
+
st.fg = st.bg = null;
|
|
6936
|
+
st.bold = st.dim = st.italic = st.underline = st.strike = st.inverse = false;
|
|
6937
|
+
st.plain = true;
|
|
6938
|
+
} else if (flag) {
|
|
6939
|
+
st[flag[0]] = flag[1];
|
|
6940
|
+
st.plain = false;
|
|
6941
|
+
} else if (c === 22) {
|
|
6942
|
+
st.bold = st.dim = false;
|
|
6943
|
+
} else if (isColor && c !== 38 && c !== 48) {
|
|
6944
|
+
const key = (c >= 40 && c <= 49) || c >= 100 ? 'bg' : 'fg';
|
|
6945
|
+
const idx = c % 10;
|
|
6946
|
+
st[key] = idx === 9 ? null : { name: c >= 90 ? `bright-${ANSI_COLOR_NAMES[idx]}` : ANSI_COLOR_NAMES[idx] };
|
|
6947
|
+
if (idx !== 9) st.plain = false;
|
|
6948
|
+
} else if (c === 38 || c === 48) {
|
|
6949
|
+
st.plain = false;
|
|
6950
|
+
const key = c === 38 ? 'fg' : 'bg';
|
|
6951
|
+
if (codes[i + 1] === 5) {
|
|
6952
|
+
st[key] = ansi256Color(codes[i + 2] || 0);
|
|
6953
|
+
i += 2;
|
|
6954
|
+
} else if (codes[i + 1] === 2) {
|
|
6955
|
+
st[key] = { rgb: `rgb(${codes[i + 2] || 0},${codes[i + 3] || 0},${codes[i + 4] || 0})` };
|
|
6956
|
+
i += 4;
|
|
6957
|
+
}
|
|
6958
|
+
}
|
|
6959
|
+
}
|
|
6960
|
+
}
|
|
6961
|
+
|
|
6962
|
+
function wrapAnsiChunk(chunk, st) {
|
|
6963
|
+
const escaped = escapeHtml(chunk);
|
|
6964
|
+
if (st.plain) return escaped;
|
|
6965
|
+
const classes = [];
|
|
6966
|
+
const styles = [];
|
|
6967
|
+
const fg = st.inverse ? st.bg : st.fg;
|
|
6968
|
+
const bg = st.inverse ? st.fg : st.bg;
|
|
6969
|
+
if (fg?.name) classes.push(`ansi-fg-${fg.name}`);
|
|
6970
|
+
else if (fg?.rgb) styles.push(`color:${fg.rgb}`);
|
|
6971
|
+
// `ansi-bg` carries the padding both background forms share; the named form
|
|
6972
|
+
// adds the colour class, the 256/truecolor form an inline background.
|
|
6973
|
+
if (bg?.name) classes.push('ansi-bg', `ansi-bg-${bg.name}`);
|
|
6974
|
+
else if (bg?.rgb) {
|
|
6975
|
+
classes.push('ansi-bg');
|
|
6976
|
+
styles.push(`background:${bg.rgb}`);
|
|
6977
|
+
}
|
|
6978
|
+
if (st.inverse && !fg && !bg) classes.push('ansi-inverse');
|
|
6979
|
+
if (st.bold) classes.push('ansi-bold');
|
|
6980
|
+
if (st.dim) classes.push('ansi-dim');
|
|
6981
|
+
if (st.italic) classes.push('ansi-italic');
|
|
6982
|
+
if (st.underline) classes.push('ansi-underline');
|
|
6983
|
+
if (st.strike) classes.push('ansi-strike');
|
|
6984
|
+
if (!classes.length && !styles.length) return escaped;
|
|
6985
|
+
// Both values are built from fixed tables and digits parsed out of the SGR
|
|
6986
|
+
// parameters, but they land in an attribute, so they go through the escaper.
|
|
6987
|
+
const cls = classes.length ? ` class="${escapeHtml(classes.join(' '))}"` : '';
|
|
6988
|
+
const style = styles.length ? ` style="${escapeHtml(styles.join(';'))}"` : '';
|
|
6989
|
+
return `<span${cls}${style}>${escaped}</span>`;
|
|
6990
|
+
}
|
|
6991
|
+
|
|
6992
|
+
// Renders terminal output as HTML with its SGR colours intact. One pass over
|
|
6993
|
+
// ANSI_TOKEN_RE: SGR tokens (capture group 1) move the state, every other token
|
|
6994
|
+
// is noise and is dropped, and the text between them is a chunk. Returns escaped
|
|
6995
|
+
// HTML — every chunk goes through escapeHtml before it is wrapped.
|
|
6996
|
+
function ansiToHtml(text) {
|
|
6997
|
+
if (typeof text !== 'string') return '';
|
|
6998
|
+
const st = {
|
|
6999
|
+
fg: null,
|
|
7000
|
+
bg: null,
|
|
7001
|
+
bold: false,
|
|
7002
|
+
dim: false,
|
|
7003
|
+
italic: false,
|
|
7004
|
+
underline: false,
|
|
7005
|
+
strike: false,
|
|
7006
|
+
inverse: false,
|
|
7007
|
+
plain: true,
|
|
7008
|
+
};
|
|
7009
|
+
let out = '';
|
|
7010
|
+
let last = 0;
|
|
7011
|
+
ANSI_TOKEN_RE.lastIndex = 0;
|
|
7012
|
+
let m = ANSI_TOKEN_RE.exec(text);
|
|
7013
|
+
while (m) {
|
|
7014
|
+
if (m.index > last) out += wrapAnsiChunk(text.slice(last, m.index), st);
|
|
7015
|
+
if (m[1] !== undefined) applySgr(m[1], st);
|
|
7016
|
+
last = m.index + m[0].length;
|
|
7017
|
+
m = ANSI_TOKEN_RE.exec(text);
|
|
7018
|
+
}
|
|
7019
|
+
if (last === 0) return escapeHtml(text);
|
|
7020
|
+
if (last < text.length) out += wrapAnsiChunk(text.slice(last), st);
|
|
7021
|
+
return out;
|
|
6184
7022
|
}
|
|
6185
7023
|
|
|
6186
7024
|
function stripTeammateWrapper(text) {
|
|
@@ -6558,13 +7396,27 @@ document.addEventListener('click', (e) => {
|
|
|
6558
7396
|
return;
|
|
6559
7397
|
}
|
|
6560
7398
|
|
|
6561
|
-
const
|
|
6562
|
-
if (
|
|
6563
|
-
|
|
6564
|
-
|
|
7399
|
+
const sgHeader = e.target.closest('.session-group-header');
|
|
7400
|
+
if (sgHeader) {
|
|
7401
|
+
if (e.target.closest('.sg-name-input')) return;
|
|
7402
|
+
e.stopPropagation();
|
|
7403
|
+
const groupId = sgHeader.dataset.groupId;
|
|
7404
|
+
const group = sgGroupById(groupId);
|
|
7405
|
+
if (!group) return;
|
|
7406
|
+
if (e.target.closest('.sg-rename')) {
|
|
7407
|
+
sgBeginRename(groupId);
|
|
7408
|
+
return;
|
|
7409
|
+
}
|
|
7410
|
+
if (e.target.closest('.sg-delete')) {
|
|
7411
|
+
if (confirm(`Delete group “${group.name}”? Its sessions and projects go back to Projects.`)) {
|
|
7412
|
+
sgDeleteGroup(groupId);
|
|
7413
|
+
renderSessions();
|
|
7414
|
+
}
|
|
7415
|
+
return;
|
|
7416
|
+
}
|
|
6565
7417
|
}
|
|
6566
7418
|
|
|
6567
|
-
const header = e.target.closest('.project-group-header');
|
|
7419
|
+
const header = e.target.closest('.session-group-header, .pinned-sub-header, .project-group-header');
|
|
6568
7420
|
if (header) {
|
|
6569
7421
|
setGroupCollapsed(header, !collapsedProjectGroups.has(header.dataset.groupPath));
|
|
6570
7422
|
}
|
|
@@ -7024,7 +7876,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
7024
7876
|
const file = opts.openFile ? escapeHtml(opts.openFile) : '';
|
|
7025
7877
|
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
7878
|
}
|
|
7027
|
-
html += `<span class="
|
|
7879
|
+
html += `<span class="row-actions">${copyBtn}${openBtn}</span>`;
|
|
7028
7880
|
});
|
|
7029
7881
|
html += `</div>`;
|
|
7030
7882
|
|
|
@@ -7287,7 +8139,7 @@ function renderGoalSubtitle(session) {
|
|
|
7287
8139
|
// Only active (unmet) goals reach here — a met goal auto-clears. Clicking
|
|
7288
8140
|
// opens the info modal (full text); stopPropagation so it doesn't also
|
|
7289
8141
|
// 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>`;
|
|
8142
|
+
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
8143
|
}
|
|
7292
8144
|
|
|
7293
8145
|
function renderLoopBadge(session) {
|
|
@@ -7811,6 +8663,8 @@ try {
|
|
|
7811
8663
|
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
7812
8664
|
cg.forEach((p) => collapsedProjectGroups.add(p));
|
|
7813
8665
|
} catch (_) {}
|
|
8666
|
+
loadSessionGroups();
|
|
8667
|
+
initSessionGroupsDnd();
|
|
7814
8668
|
try {
|
|
7815
8669
|
const af = JSON.parse(localStorage.getItem('activityFilter') || '[]');
|
|
7816
8670
|
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|