claude-code-kanban 4.20.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/README.md +1 -0
- package/lib/approvals.js +82 -0
- package/lib/file-url.js +36 -0
- package/package.json +2 -2
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +2 -24
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +17 -3
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +141 -0
- package/public/app.js +1335 -165
- package/public/index.html +1 -0
- package/public/style.css +648 -39
- package/server.js +256 -81
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;
|
|
@@ -567,8 +569,6 @@ async function refreshProjectAgents() {
|
|
|
567
569
|
renderAgentFooter();
|
|
568
570
|
}
|
|
569
571
|
|
|
570
|
-
//#endregion
|
|
571
|
-
|
|
572
572
|
//#region MESSAGE_PANEL
|
|
573
573
|
function toggleMessagePanel() {
|
|
574
574
|
const panel = document.getElementById('message-panel');
|
|
@@ -1180,18 +1180,19 @@ function toggleToolGroup(id) {
|
|
|
1180
1180
|
if (el) el.classList.toggle('show');
|
|
1181
1181
|
}
|
|
1182
1182
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
if (tool === 'ExitPlanMode') return 'Plan awaiting approval';
|
|
1186
|
-
return 'Question pending';
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1183
|
+
// Legacy markers (written before the gate minted kind:"plan") carry no kind
|
|
1184
|
+
// for plans — the tool name is the durable signal there.
|
|
1189
1185
|
function getWaitingPill(kind, tool) {
|
|
1190
|
-
if (kind === '
|
|
1186
|
+
if (kind === 'plan' || tool === 'ExitPlanMode') return 'Plan awaiting approval';
|
|
1191
1187
|
if (kind === 'question') return 'Question pending';
|
|
1192
1188
|
return 'Awaiting permission';
|
|
1193
1189
|
}
|
|
1194
1190
|
|
|
1191
|
+
function getWaitingLabel(kind, tool) {
|
|
1192
|
+
const pill = getWaitingPill(kind, tool);
|
|
1193
|
+
return pill === 'Awaiting permission' ? `${pill}: ${tool}` : pill;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1195
1196
|
function deriveWaitingDetail(tool, params) {
|
|
1196
1197
|
if (!params) return '';
|
|
1197
1198
|
const trunc = (s) => (s.length > 80 ? `${s.slice(0, 80)}...` : s);
|
|
@@ -1235,17 +1236,40 @@ function renderWaitingBody(tool, params) {
|
|
|
1235
1236
|
return renderToolParamsHtml(params);
|
|
1236
1237
|
}
|
|
1237
1238
|
|
|
1239
|
+
const PLAN_REJECT_MSG = 'Plan rejected from the kanban board.';
|
|
1240
|
+
|
|
1241
|
+
// Answerable only when the marker carries a request id (written by
|
|
1242
|
+
// approval-gate.sh, which is polling for a decision) and the gate hasn't
|
|
1243
|
+
// lapsed past its wait window — older or lapsed markers are only answerable
|
|
1244
|
+
// in the terminal.
|
|
1245
|
+
function isWaitingAnswerable() {
|
|
1246
|
+
return !!(currentWaiting?.id && !currentWaiting.lapsed);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function parseWaitingInput() {
|
|
1250
|
+
try {
|
|
1251
|
+
return JSON.parse(currentWaiting?.toolInput || '');
|
|
1252
|
+
} catch (_) {
|
|
1253
|
+
return null; /* toolInput may be truncated/non-JSON */
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// One source for the decision-button pair on every surface — Approve/Reject
|
|
1258
|
+
// for plans, Allow/Deny for permissions; only the css class prefix differs.
|
|
1259
|
+
// Questions return '' (answered via the form in the detail modal).
|
|
1260
|
+
function waitingDecisionButtons(cls) {
|
|
1261
|
+
if (!isWaitingAnswerable() || currentWaiting.kind === 'question') return '';
|
|
1262
|
+
const plan = currentWaiting.kind === 'plan';
|
|
1263
|
+
// Code-authored call argument, not data — hence the constant-style name the
|
|
1264
|
+
// escaping check reads as safe.
|
|
1265
|
+
const DENY_ARG = plan ? "{behavior:'deny',message:PLAN_REJECT_MSG}" : "{behavior:'deny'}";
|
|
1266
|
+
return `<button class="${escapeHtml(cls)}-allow" onclick="event.stopPropagation();respondWaiting({behavior:'allow'})">${plan ? 'Approve' : 'Allow'}</button><button class="${escapeHtml(cls)}-deny" onclick="event.stopPropagation();respondWaiting(${DENY_ARG})">${plan ? 'Reject' : 'Deny'}</button>`;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1238
1269
|
function renderWaitingEntry() {
|
|
1239
1270
|
if (!isWaitingFresh()) return '';
|
|
1240
1271
|
const tool = currentWaiting.toolName || 'unknown';
|
|
1241
|
-
|
|
1242
|
-
if (currentWaiting.toolInput) {
|
|
1243
|
-
try {
|
|
1244
|
-
params = JSON.parse(currentWaiting.toolInput);
|
|
1245
|
-
} catch (_) {
|
|
1246
|
-
/* toolInput may be truncated/non-JSON */
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1272
|
+
const params = parseWaitingInput();
|
|
1249
1273
|
const pillText = getWaitingPill(currentWaiting.kind, tool);
|
|
1250
1274
|
const detail = deriveWaitingDetail(tool, params);
|
|
1251
1275
|
const detailHtml = detail ? ` <span style="color:var(--text-secondary)">${escapeHtml(detail)}</span>` : '';
|
|
@@ -1253,7 +1277,73 @@ function renderWaitingEntry() {
|
|
|
1253
1277
|
const bodyWrap = bodyHtml ? `<div class="msg-waiting-body">${bodyHtml}</div>` : '';
|
|
1254
1278
|
const pill = `<span class="msg-waiting-pill">${escapeHtml(pillText)}</span>`;
|
|
1255
1279
|
const discardBtn = `<button class="msg-waiting-discard" title="Discard permission prompt" onclick="event.stopPropagation();discardWaiting()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>`;
|
|
1256
|
-
|
|
1280
|
+
// Questions are answered in the detail modal; plans render there too.
|
|
1281
|
+
const buttons = waitingDecisionButtons('msg-waiting');
|
|
1282
|
+
const actions = buttons ? `<span class="msg-waiting-actions">${buttons}</span>` : '';
|
|
1283
|
+
// Lapsed = gate stopped polling — the ask is only answerable in the terminal
|
|
1284
|
+
const lapsed = !!currentWaiting.lapsed;
|
|
1285
|
+
return `<div class="msg-item msg-waiting" onclick="msgDetailFollowLatest=false;showWaitingDetail()">${getToolIcon(tool)}<div class="msg-body"><div class="msg-text">${pill} <span style="font-weight:600">${escapeHtml(tool)}</span>${detailHtml}</div>${bodyWrap}<div class="msg-waiting-footer"><div class="msg-time">${lapsed ? 'answer in the terminal' : 'waiting…'}</div>${actions}</div></div>${discardBtn}</div>`;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Shared approval controls for a fresh plan ask — the waiting-detail footer and
|
|
1289
|
+
// the saved-plan modal offer the same decision, so they render the same row.
|
|
1290
|
+
// Distinct input ids keep the two surfaces from shadowing each other's feedback.
|
|
1291
|
+
function planApprovalControlsHtml(inputId) {
|
|
1292
|
+
return `<input id="${escapeHtml(inputId)}" class="waiting-option-input" type="text" placeholder="feedback for reject (optional)…"><button class="waiting-btn-allow" onclick="respondWaiting({behavior:'allow'})">Approve</button><button class="waiting-btn-deny" onclick="rejectWaitingPlan('${escAttrJs(inputId)}')">Reject</button>`;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
1296
|
+
function rejectWaitingPlan(inputId) {
|
|
1297
|
+
const feedback = document.getElementById(inputId)?.value?.trim();
|
|
1298
|
+
respondWaiting({ behavior: 'deny', message: feedback || PLAN_REJECT_MSG });
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// UI-driven approvals: POST the decision the blocking approval-gate.sh hook is
|
|
1302
|
+
// polling for. First writer wins (D5) — a 409/410 means the ask was answered in
|
|
1303
|
+
// the terminal or superseded, so just drop the card.
|
|
1304
|
+
async function respondWaiting(payload) {
|
|
1305
|
+
if (!currentSessionId || !currentWaiting?.id) return;
|
|
1306
|
+
try {
|
|
1307
|
+
const res = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/waiting/respond`, {
|
|
1308
|
+
method: 'POST',
|
|
1309
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1310
|
+
body: JSON.stringify({ id: currentWaiting.id, ...payload }),
|
|
1311
|
+
});
|
|
1312
|
+
if (res.ok || res.status === 409 || res.status === 410) {
|
|
1313
|
+
clearWaitingUi();
|
|
1314
|
+
} else {
|
|
1315
|
+
// Surface a respond failure — a silent console error reads as a dead button
|
|
1316
|
+
const text = await res.text();
|
|
1317
|
+
console.error('[respondWaiting]', res.status, text);
|
|
1318
|
+
let msg = text;
|
|
1319
|
+
try {
|
|
1320
|
+
msg = JSON.parse(text).error || text;
|
|
1321
|
+
} catch (_) {
|
|
1322
|
+
/* plain text */
|
|
1323
|
+
}
|
|
1324
|
+
showToast(msg, 'error');
|
|
1325
|
+
}
|
|
1326
|
+
} catch (e) {
|
|
1327
|
+
console.error('[respondWaiting]', e);
|
|
1328
|
+
showToast(String(e), 'error');
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// Drop the answered/discarded ask from every surface it renders on
|
|
1333
|
+
function clearWaitingUi() {
|
|
1334
|
+
currentWaiting = null;
|
|
1335
|
+
waitingAnswerDraft = {};
|
|
1336
|
+
waitingCustomDraft = {};
|
|
1337
|
+
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
1338
|
+
// In follow mode stay in the modal and swap to the latest message —
|
|
1339
|
+
// closing it would silently drop the user out of following.
|
|
1340
|
+
if (msgDetailFollowLatest && currentMessages.length) showMsgDetail(currentMessages.length - 1);
|
|
1341
|
+
else closeMsgDetailModal();
|
|
1342
|
+
}
|
|
1343
|
+
// The saved-plan modal stays open after a decision — only its approval row goes
|
|
1344
|
+
document.getElementById('plan-approval-footer')?.remove();
|
|
1345
|
+
renderMessages(currentMessages);
|
|
1346
|
+
renderAgentFooter();
|
|
1257
1347
|
}
|
|
1258
1348
|
|
|
1259
1349
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
@@ -1263,12 +1353,7 @@ async function discardWaiting() {
|
|
|
1263
1353
|
const res = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/waiting/discard`, {
|
|
1264
1354
|
method: 'POST',
|
|
1265
1355
|
});
|
|
1266
|
-
if (res.ok)
|
|
1267
|
-
currentWaiting = null;
|
|
1268
|
-
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) closeMsgDetailModal();
|
|
1269
|
-
renderMessages(currentMessages);
|
|
1270
|
-
renderAgentFooter();
|
|
1271
|
-
}
|
|
1356
|
+
if (res.ok) clearWaitingUi();
|
|
1272
1357
|
} catch (e) {
|
|
1273
1358
|
console.error('[discardWaiting]', e);
|
|
1274
1359
|
}
|
|
@@ -1662,7 +1747,7 @@ function _renderPinToDetail(pin) {
|
|
|
1662
1747
|
const pinResultHtml = pinFindings
|
|
1663
1748
|
? ''
|
|
1664
1749
|
: renderToolResultHtml(pin.toolResult, pin.toolResultTruncated, pin.toolResultFull, pin.toolUseId);
|
|
1665
|
-
const pinDetailEscaped = escapeHtml(fullText);
|
|
1750
|
+
const pinDetailEscaped = escapeHtml(stripAnsi(fullText));
|
|
1666
1751
|
const pinDetailRendered = pin.tool === 'Bash' ? highlightBash(pinDetailEscaped) : pinDetailEscaped;
|
|
1667
1752
|
// Tool-result images are intentionally omitted here: their URL is built from
|
|
1668
1753
|
// the global currentSessionId, but a pin can belong to a different session,
|
|
@@ -1734,7 +1819,7 @@ function renderUserAttachments(m) {
|
|
|
1734
1819
|
.map((ref) => {
|
|
1735
1820
|
const safeId = escapeHtml(ref.toolUseId);
|
|
1736
1821
|
const shortId = ref.toolUseId.length > 14 ? `${ref.toolUseId.slice(0, 14)}…` : ref.toolUseId;
|
|
1737
|
-
const preview = ref.preview ?
|
|
1822
|
+
const preview = ref.preview ? sanitizeOutputHtml(ref.preview) : '<em>(no text)</em>';
|
|
1738
1823
|
const expandId = `user-tr-${ref.toolUseId}`;
|
|
1739
1824
|
return `<details class="user-attach-toolresult">
|
|
1740
1825
|
<summary>Tool result <code>${escapeHtml(shortId)}</code></summary>
|
|
@@ -1762,6 +1847,7 @@ function highlightSelectedMsg() {
|
|
|
1762
1847
|
|
|
1763
1848
|
function showMsgDetail(idx) {
|
|
1764
1849
|
currentMsgDetailIdx = idx;
|
|
1850
|
+
document.getElementById('msg-detail-waiting-footer').innerHTML = '';
|
|
1765
1851
|
msgHighlightDimmed = false;
|
|
1766
1852
|
const m = currentMessages[idx];
|
|
1767
1853
|
if (!m) return;
|
|
@@ -1811,7 +1897,7 @@ function showMsgDetail(idx) {
|
|
|
1811
1897
|
} else if (taskResultHtml || findingsHtml) {
|
|
1812
1898
|
mainHtml = descHtml || '';
|
|
1813
1899
|
} else if (fullText) {
|
|
1814
|
-
const detailEscaped = escapeHtml(fullText);
|
|
1900
|
+
const detailEscaped = escapeHtml(stripAnsi(fullText));
|
|
1815
1901
|
const detailRendered = m.tool === 'Bash' ? highlightBash(detailEscaped) : detailEscaped;
|
|
1816
1902
|
mainHtml = `${descHtml}<pre class="${TINTED_PRE_CLASS}">${detailRendered}</pre>`;
|
|
1817
1903
|
} else {
|
|
@@ -2154,7 +2240,7 @@ function getToolDetail(tool, params, detail) {
|
|
|
2154
2240
|
}
|
|
2155
2241
|
function renderTaskResult(toolResult) {
|
|
2156
2242
|
if (!toolResult) return '';
|
|
2157
|
-
const lines = toolResult.trim().split('\n');
|
|
2243
|
+
const lines = sanitizeOutput(toolResult).trim().split('\n');
|
|
2158
2244
|
const fields = [];
|
|
2159
2245
|
for (const line of lines) {
|
|
2160
2246
|
const m = line.match(/^([A-Za-z #]+):\s*(.+)$/);
|
|
@@ -2263,7 +2349,7 @@ function renderFindingsReport(params, toolResult) {
|
|
|
2263
2349
|
});
|
|
2264
2350
|
html += '</div>';
|
|
2265
2351
|
}
|
|
2266
|
-
if (toolResult) html += `<div class="findings-result-note">${
|
|
2352
|
+
if (toolResult) html += `<div class="findings-result-note">${sanitizeOutputHtml(toolResult.trim())}</div>`;
|
|
2267
2353
|
return `${html}</div>`;
|
|
2268
2354
|
}
|
|
2269
2355
|
//#endregion
|
|
@@ -2380,6 +2466,16 @@ function stripLineNumbers(text) {
|
|
|
2380
2466
|
return text.replace(/^ *\d+[→\t]/gm, '');
|
|
2381
2467
|
}
|
|
2382
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
|
+
|
|
2383
2479
|
function highlightBash(escaped) {
|
|
2384
2480
|
return escaped
|
|
2385
2481
|
.replace(/^(\s*)(#.*)$/gm, '$1<span style="color:#6a9955">$2</span>')
|
|
@@ -2436,19 +2532,20 @@ function autoSizeModal(modal, body) {
|
|
|
2436
2532
|
}
|
|
2437
2533
|
const hasTable = body.querySelector('table') !== null;
|
|
2438
2534
|
const hasPre = body.querySelector('pre') !== null;
|
|
2439
|
-
|
|
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;
|
|
2440
2538
|
const current = parseFloat(getComputedStyle(modal).maxWidth) || 0;
|
|
2441
2539
|
if (desired > current) modal.style.maxWidth = `${desired}px`;
|
|
2442
2540
|
}
|
|
2443
2541
|
|
|
2444
2542
|
function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
|
|
2445
2543
|
if (!toolResult) return '';
|
|
2446
|
-
const
|
|
2447
|
-
const escaped = escapeHtml(stripped);
|
|
2544
|
+
const escaped = sanitizeOutputHtml(toolResult);
|
|
2448
2545
|
let truncLabel = '',
|
|
2449
2546
|
fullBlock = '';
|
|
2450
2547
|
if (isTruncated && fullResult) {
|
|
2451
|
-
const toggle = makeExpandToggle(escaped,
|
|
2548
|
+
const toggle = makeExpandToggle(escaped, sanitizeOutputHtml(fullResult));
|
|
2452
2549
|
truncLabel = toggle.btn;
|
|
2453
2550
|
fullBlock = toggle.full;
|
|
2454
2551
|
} else if (isTruncated && toolUseId) {
|
|
@@ -2490,7 +2587,7 @@ async function _toggleToolResultExpand(btn) {
|
|
|
2490
2587
|
);
|
|
2491
2588
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
2492
2589
|
const { content } = await r.json();
|
|
2493
|
-
f.
|
|
2590
|
+
f.innerHTML = sanitizeOutputHtml(content);
|
|
2494
2591
|
btn.dataset.loaded = '1';
|
|
2495
2592
|
} catch (_e) {
|
|
2496
2593
|
btn.textContent = 'Show more';
|
|
@@ -2505,7 +2602,7 @@ async function _toggleToolResultExpand(btn) {
|
|
|
2505
2602
|
|
|
2506
2603
|
function buildToolContent(m) {
|
|
2507
2604
|
let content = m.fullDetail || m.detail || '';
|
|
2508
|
-
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)}`;
|
|
2509
2606
|
return content;
|
|
2510
2607
|
}
|
|
2511
2608
|
|
|
@@ -2630,7 +2727,7 @@ function renderAgentFooter() {
|
|
|
2630
2727
|
document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '▴' : '▾';
|
|
2631
2728
|
|
|
2632
2729
|
const permHtml = permFresh
|
|
2633
|
-
? `<
|
|
2730
|
+
? `<button type="button" class="permission-badge" onclick="showWaitingDetail()">${currentWaiting.kind === 'question' ? '❓ Question pending' : currentWaiting.kind === 'plan' ? '📋 Plan pending' : `⏳ Awaiting: ${escapeHtml(currentWaiting.toolName || 'unknown')}`}</button>`
|
|
2634
2731
|
: '';
|
|
2635
2732
|
|
|
2636
2733
|
content.innerHTML =
|
|
@@ -2918,6 +3015,9 @@ function renderAllTasks() {
|
|
|
2918
3015
|
}
|
|
2919
3016
|
|
|
2920
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;
|
|
2921
3021
|
// Update project dropdown
|
|
2922
3022
|
updateProjectDropdown();
|
|
2923
3023
|
|
|
@@ -2970,12 +3070,14 @@ function renderSessions() {
|
|
|
2970
3070
|
)
|
|
2971
3071
|
taskMatchIds.add(t.sessionId);
|
|
2972
3072
|
}
|
|
3073
|
+
const groupMatchIds = sgSearchMatchIds(searchQuery);
|
|
2973
3074
|
const matchesSearch = (s) =>
|
|
2974
3075
|
(s.name && fuzzyMatch(s.name, searchQuery)) ||
|
|
2975
3076
|
(s.id && fuzzyMatch(s.id, searchQuery)) ||
|
|
2976
3077
|
(s.project && fuzzyMatch(s.project, searchQuery)) ||
|
|
2977
3078
|
(s.description && fuzzyMatch(s.description, searchQuery)) ||
|
|
2978
|
-
taskMatchIds.has(s.id)
|
|
3079
|
+
taskMatchIds.has(s.id) ||
|
|
3080
|
+
groupMatchIds.has(s.id);
|
|
2979
3081
|
|
|
2980
3082
|
filteredSessions = filteredSessions.filter(matchesSearch);
|
|
2981
3083
|
|
|
@@ -3068,7 +3170,7 @@ function renderSessions() {
|
|
|
3068
3170
|
const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
|
|
3069
3171
|
const sid = escAttrJs(session.id);
|
|
3070
3172
|
return `
|
|
3071
|
-
<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)}">
|
|
3072
3174
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
3073
3175
|
<div class="session-name">${escapeHtml(primaryName)}</div>
|
|
3074
3176
|
${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
|
|
@@ -3100,13 +3202,162 @@ function renderSessions() {
|
|
|
3100
3202
|
`;
|
|
3101
3203
|
};
|
|
3102
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
|
+
|
|
3103
3352
|
// Group active sessions by project
|
|
3104
3353
|
if (sessionFilter === 'active') {
|
|
3105
3354
|
// Auto-expand a collapsed section when newly-active work lands in it (live refresh).
|
|
3106
3355
|
expandActiveGroups({ onlyNew: true });
|
|
3356
|
+
html += sgSectionHtml(false);
|
|
3357
|
+
|
|
3107
3358
|
const groups = new Map();
|
|
3108
3359
|
const ungrouped = [];
|
|
3109
|
-
for (const session of
|
|
3360
|
+
for (const session of sgRest) {
|
|
3110
3361
|
if (session.project) {
|
|
3111
3362
|
if (!groups.has(session.project)) groups.set(session.project, []);
|
|
3112
3363
|
groups.get(session.project).push(session);
|
|
@@ -3114,106 +3365,42 @@ function renderSessions() {
|
|
|
3114
3365
|
ungrouped.push(session);
|
|
3115
3366
|
}
|
|
3116
3367
|
}
|
|
3117
|
-
const groupPinned = localStorage.getItem('groupPinnedSessions') !== 'false';
|
|
3118
|
-
const renderGroupSessions = (sessions, pinKey) => {
|
|
3119
|
-
if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
|
|
3120
|
-
const gPinned = sessions.filter((s) => isPlacedPinned(s.id) && !isPlacedSticky(s.id));
|
|
3121
|
-
if (gPinned.length === 0) return sessions.map(renderSessionCard).join('');
|
|
3122
|
-
const gIdlePinned = gPinned.filter((s) => !isSessionActive(s));
|
|
3123
|
-
const gUnpinned = sessions.filter((s) => !isPlacedPinned(s.id) || isSessionActive(s) || isPlacedSticky(s.id));
|
|
3124
|
-
const pinCollapsed = collapsedProjectGroups.has(pinKey);
|
|
3125
|
-
if (gIdlePinned.length === 0 && !pinCollapsed) return gUnpinned.map(renderSessionCard).join('');
|
|
3126
|
-
return (
|
|
3127
|
-
'<div class="pinned-sub-section">' +
|
|
3128
|
-
'<div class="pinned-sub-header' +
|
|
3129
|
-
(pinCollapsed ? ' collapsed' : '') +
|
|
3130
|
-
'" data-group-path="' +
|
|
3131
|
-
escapeHtml(pinKey) +
|
|
3132
|
-
'">' +
|
|
3133
|
-
'<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>' +
|
|
3134
|
-
'<span class="pinned-sub-label">Pinned</span>' +
|
|
3135
|
-
'<span class="group-count">' +
|
|
3136
|
-
gIdlePinned.length +
|
|
3137
|
-
'</span>' +
|
|
3138
|
-
'<span class="pinned-ungroup-btn" title="Ungroup pinned sessions">×</span>' +
|
|
3139
|
-
'</div>' +
|
|
3140
|
-
'<div class="pinned-sub-items' +
|
|
3141
|
-
(pinCollapsed ? ' collapsed' : '') +
|
|
3142
|
-
'">' +
|
|
3143
|
-
gIdlePinned.map(renderSessionCard).join('') +
|
|
3144
|
-
'</div>' +
|
|
3145
|
-
'</div>' +
|
|
3146
|
-
gUnpinned.map(renderSessionCard).join('')
|
|
3147
|
-
);
|
|
3148
|
-
};
|
|
3149
3368
|
if (!groupPinned && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
|
|
3150
|
-
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isPlacedPinned(s.id) && !isSessionActive(s) ? 1 : 0);
|
|
3151
|
-
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
3152
3369
|
for (const [, arr] of groups) arr.sort(pinSort);
|
|
3153
3370
|
ungrouped.sort(pinSort);
|
|
3154
3371
|
}
|
|
3155
3372
|
|
|
3156
|
-
// Stable group order: preserve existing order, append new groups sorted by recency
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
const
|
|
3160
|
-
const
|
|
3161
|
-
|
|
3162
|
-
const
|
|
3163
|
-
|
|
3164
|
-
);
|
|
3165
|
-
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));
|
|
3166
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));
|
|
3167
3386
|
stableGroupOrder = [...keptOrder, ...newPaths];
|
|
3168
|
-
const sortedGroups = stableGroupOrder.map((p) => [p, groups.get(p)]);
|
|
3387
|
+
const sortedGroups = stableGroupOrder.filter((p) => groups.has(p)).map((p) => [p, groups.get(p)]);
|
|
3169
3388
|
|
|
3170
|
-
|
|
3171
|
-
if (
|
|
3172
|
-
|
|
3173
|
-
if (hasPinnedInView) {
|
|
3174
|
-
html += `<div class="pinned-regroup-banner">
|
|
3175
|
-
<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>
|
|
3176
|
-
Group pinned sessions
|
|
3177
|
-
</div>`;
|
|
3178
|
-
}
|
|
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>';
|
|
3179
3392
|
}
|
|
3180
3393
|
for (const [projectPath, projectSessions] of sortedGroups) {
|
|
3181
|
-
|
|
3182
|
-
const isCollapsed = collapsedProjectGroups.has(projectPath);
|
|
3183
|
-
const escapedPath = escapeHtml(projectPath);
|
|
3184
|
-
const activeCount = projectSessions.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
3185
|
-
const breadcrumbParts = projectPath
|
|
3186
|
-
.replace(/^\/home\/[^/]+/, '~')
|
|
3187
|
-
.split(/[/\\]/)
|
|
3188
|
-
.filter(Boolean);
|
|
3189
|
-
const breadcrumbHtml = breadcrumbParts
|
|
3190
|
-
.map((p, i) => (i < breadcrumbParts.length - 1 ? `${escapeHtml(p)}<span class="sep">/</span>` : escapeHtml(p)))
|
|
3191
|
-
.join('');
|
|
3192
|
-
|
|
3193
|
-
html += `
|
|
3194
|
-
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="${escapedPath}">
|
|
3195
|
-
<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>
|
|
3196
|
-
<span class="group-name">${escapeHtml(folderName)}</span>
|
|
3197
|
-
<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>
|
|
3198
|
-
<span class="project-view-btn" data-project-path="${escapedPath}" title="Open project view — combined tasks from all sessions">
|
|
3199
|
-
<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>
|
|
3200
|
-
</span>
|
|
3201
|
-
</div>
|
|
3202
|
-
<div class="project-group-breadcrumb" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
|
|
3203
|
-
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
|
|
3204
|
-
${renderGroupSessions(projectSessions, `__pinned_${projectPath}__`)}
|
|
3205
|
-
</div>
|
|
3206
|
-
`;
|
|
3394
|
+
html += projectBlock(projectPath, projectSessions, false);
|
|
3207
3395
|
}
|
|
3208
3396
|
|
|
3209
3397
|
if (ungrouped.length > 0 && sortedGroups.length > 0) {
|
|
3210
3398
|
const isCollapsed = collapsedProjectGroups.has('__ungrouped__');
|
|
3211
|
-
const ungroupedActive = ungrouped.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
3212
3399
|
html += `
|
|
3213
3400
|
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__ungrouped__">
|
|
3214
|
-
|
|
3401
|
+
${groupChevronSvg()}
|
|
3215
3402
|
<span class="group-name">Ungrouped</span>
|
|
3216
|
-
|
|
3403
|
+
${countHtml(ungrouped)}
|
|
3217
3404
|
</div>
|
|
3218
3405
|
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
|
|
3219
3406
|
${renderGroupSessions(ungrouped, '__pinned___ungrouped__')}
|
|
@@ -3225,7 +3412,11 @@ function renderSessions() {
|
|
|
3225
3412
|
|
|
3226
3413
|
sessionsList.innerHTML = html;
|
|
3227
3414
|
} else {
|
|
3228
|
-
|
|
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('');
|
|
3229
3420
|
}
|
|
3230
3421
|
|
|
3231
3422
|
const navItems = getNavigableItems();
|
|
@@ -3368,6 +3559,59 @@ function renderTaskCard(task) {
|
|
|
3368
3559
|
//#endregion
|
|
3369
3560
|
|
|
3370
3561
|
//#region KANBAN
|
|
3562
|
+
// FLIP pass around renderKanban's innerHTML rebuild (#41): the rebuild lands cards at
|
|
3563
|
+
// their final spot instantly, so a status change reads as a full-board reload. Recording
|
|
3564
|
+
// where each card was and animating from there makes a move glide instead.
|
|
3565
|
+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
3566
|
+
|
|
3567
|
+
function cardFlipKey(el) {
|
|
3568
|
+
return `${el.dataset.sessionId}|${el.dataset.taskId}`;
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
function captureCardRects() {
|
|
3572
|
+
const rects = new Map();
|
|
3573
|
+
for (const el of document.querySelectorAll('.column-tasks .task-card')) {
|
|
3574
|
+
rects.set(cardFlipKey(el), el.getBoundingClientRect());
|
|
3575
|
+
}
|
|
3576
|
+
return rects;
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
function playCardFlip(before) {
|
|
3580
|
+
// An empty board before the render means a view switch, not a move — nothing to glide.
|
|
3581
|
+
if (reducedMotion.matches || before.size === 0) return;
|
|
3582
|
+
for (const el of document.querySelectorAll('.column-tasks .task-card')) {
|
|
3583
|
+
const prev = before.get(cardFlipKey(el));
|
|
3584
|
+
if (!prev) {
|
|
3585
|
+
el.animate(
|
|
3586
|
+
[
|
|
3587
|
+
{ opacity: 0, transform: 'scale(0.97)' },
|
|
3588
|
+
{ opacity: 1, transform: 'none' },
|
|
3589
|
+
],
|
|
3590
|
+
{ duration: 150, easing: 'ease-out' },
|
|
3591
|
+
);
|
|
3592
|
+
continue;
|
|
3593
|
+
}
|
|
3594
|
+
const now = el.getBoundingClientRect();
|
|
3595
|
+
const dx = prev.left - now.left;
|
|
3596
|
+
const dy = prev.top - now.top;
|
|
3597
|
+
if (dx || dy) {
|
|
3598
|
+
el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], {
|
|
3599
|
+
duration: 220,
|
|
3600
|
+
easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)',
|
|
3601
|
+
});
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
// Rewrite a column only when its markup actually changed: SSE ticks fire on any task-file
|
|
3607
|
+
// write (updatedAt bumps, agent refreshes), and an unconditional innerHTML rebuild makes
|
|
3608
|
+
// the whole board blink for updates with nothing visible in them (#41).
|
|
3609
|
+
function setColumnHtml(el, html) {
|
|
3610
|
+
if (el._lastHtml === html) return;
|
|
3611
|
+
el._lastHtml = html;
|
|
3612
|
+
el.innerHTML = html;
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3371
3615
|
function renderKanban() {
|
|
3372
3616
|
let filtered = currentTasks.filter((t) => !isInternalTask(t));
|
|
3373
3617
|
if (ownerFilter) {
|
|
@@ -3384,27 +3628,41 @@ function renderKanban() {
|
|
|
3384
3628
|
const emptyIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>`;
|
|
3385
3629
|
const plusIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 5v14M5 12h14"/></svg>`;
|
|
3386
3630
|
|
|
3631
|
+
const writes = [];
|
|
3387
3632
|
// Adding is a live text input inside the column, so a background refresh would blow it
|
|
3388
3633
|
// away mid-typing -- leave the column alone until the input is gone.
|
|
3389
3634
|
if (!addingTask) {
|
|
3390
3635
|
const addTile = canAddTask()
|
|
3391
3636
|
? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this)">${plusIcon}<span>Add task</span></button>`
|
|
3392
3637
|
: '';
|
|
3393
|
-
|
|
3638
|
+
writes.push([
|
|
3639
|
+
pendingTasks,
|
|
3394
3640
|
pending.length > 0
|
|
3395
3641
|
? pending.map(renderTaskCard).join('') + addTile
|
|
3396
|
-
: addTile || `<div class="column-empty">${emptyIcon}<div>No pending tasks</div></div
|
|
3642
|
+
: addTile || `<div class="column-empty">${emptyIcon}<div>No pending tasks</div></div>`,
|
|
3643
|
+
]);
|
|
3397
3644
|
}
|
|
3398
3645
|
|
|
3399
|
-
|
|
3646
|
+
writes.push([
|
|
3647
|
+
inProgressTasks,
|
|
3400
3648
|
inProgress.length > 0
|
|
3401
3649
|
? inProgress.map(renderTaskCard).join('')
|
|
3402
|
-
: `<div class="column-empty">${emptyIcon}<div>No active tasks</div></div
|
|
3650
|
+
: `<div class="column-empty">${emptyIcon}<div>No active tasks</div></div>`,
|
|
3651
|
+
]);
|
|
3403
3652
|
|
|
3404
|
-
|
|
3653
|
+
writes.push([
|
|
3654
|
+
completedTasks,
|
|
3405
3655
|
completed.length > 0
|
|
3406
3656
|
? completed.map(renderTaskCard).join('')
|
|
3407
|
-
: `<div class="column-empty">${emptyIcon}<div>No completed tasks</div></div
|
|
3657
|
+
: `<div class="column-empty">${emptyIcon}<div>No completed tasks</div></div>`,
|
|
3658
|
+
]);
|
|
3659
|
+
|
|
3660
|
+
// Capture rects (a forced layout read per card) only when a column will
|
|
3661
|
+
// actually be rewritten — most SSE ticks change nothing and skip the FLIP.
|
|
3662
|
+
const willChange = writes.some(([el, html]) => el._lastHtml !== html);
|
|
3663
|
+
const flipRects = willChange ? captureCardRects() : null;
|
|
3664
|
+
for (const [el, html] of writes) setColumnHtml(el, html);
|
|
3665
|
+
if (flipRects) playCardFlip(flipRects);
|
|
3408
3666
|
|
|
3409
3667
|
if (selectedTaskId) {
|
|
3410
3668
|
const card =
|
|
@@ -3444,6 +3702,9 @@ function startAddTask(tile) {
|
|
|
3444
3702
|
input.type = 'text';
|
|
3445
3703
|
input.className = 'form-input column-add-input';
|
|
3446
3704
|
input.placeholder = 'Task subject, Enter to add';
|
|
3705
|
+
// The input mutates the column outside setColumnHtml — drop the cache so the
|
|
3706
|
+
// next render rebuilds instead of skipping on a stale match.
|
|
3707
|
+
pendingTasks._lastHtml = null;
|
|
3447
3708
|
tile.replaceWith(input);
|
|
3448
3709
|
input.focus();
|
|
3449
3710
|
|
|
@@ -3523,6 +3784,9 @@ function onCardDragEnd(e) {
|
|
|
3523
3784
|
|
|
3524
3785
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3525
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;
|
|
3526
3790
|
e.preventDefault();
|
|
3527
3791
|
e.dataTransfer.dropEffect = 'move';
|
|
3528
3792
|
e.currentTarget.classList.add('drag-over');
|
|
@@ -3537,6 +3801,7 @@ function onColumnDragLeave(e) {
|
|
|
3537
3801
|
|
|
3538
3802
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3539
3803
|
async function onColumnDrop(e) {
|
|
3804
|
+
if (sgDrag) return;
|
|
3540
3805
|
e.preventDefault();
|
|
3541
3806
|
e.currentTarget.classList.remove('drag-over');
|
|
3542
3807
|
const newStatus = e.currentTarget.dataset.status;
|
|
@@ -3566,6 +3831,513 @@ async function onColumnDrop(e) {
|
|
|
3566
3831
|
|
|
3567
3832
|
//#endregion
|
|
3568
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
|
+
|
|
3569
4341
|
//#region KEYBOARD_NAV
|
|
3570
4342
|
function selectTask(taskId, sessionId) {
|
|
3571
4343
|
clearTaskSelection();
|
|
@@ -3640,7 +4412,11 @@ function getKbId(el) {
|
|
|
3640
4412
|
}
|
|
3641
4413
|
|
|
3642
4414
|
function getGroupSessionsContainer(header) {
|
|
3643
|
-
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';
|
|
3644
4420
|
let el = header.nextElementSibling;
|
|
3645
4421
|
while (el && !el.classList.contains(cls)) el = el.nextElementSibling;
|
|
3646
4422
|
return el;
|
|
@@ -3648,6 +4424,7 @@ function getGroupSessionsContainer(header) {
|
|
|
3648
4424
|
|
|
3649
4425
|
function getNavigableItems() {
|
|
3650
4426
|
const items = [];
|
|
4427
|
+
// A named group nests project blocks inside its body, so the walk has to recurse.
|
|
3651
4428
|
const walkGroupContainer = (container) => {
|
|
3652
4429
|
if (!container) return;
|
|
3653
4430
|
for (const child of container.children) {
|
|
@@ -3660,11 +4437,16 @@ function getNavigableItems() {
|
|
|
3660
4437
|
}
|
|
3661
4438
|
} else if (child.classList.contains('session-item')) {
|
|
3662
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
|
+
}
|
|
3663
4445
|
}
|
|
3664
4446
|
}
|
|
3665
4447
|
};
|
|
3666
4448
|
for (const el of sessionsList.children) {
|
|
3667
|
-
if (el.classList.contains('project-group-header')) {
|
|
4449
|
+
if (el.classList.contains('project-group-header') || el.classList.contains('session-group-header')) {
|
|
3668
4450
|
items.push(el);
|
|
3669
4451
|
if (!collapsedProjectGroups.has(el.dataset.groupPath)) {
|
|
3670
4452
|
walkGroupContainer(getGroupSessionsContainer(el));
|
|
@@ -3753,6 +4535,9 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
3753
4535
|
activeIds.add(s.id);
|
|
3754
4536
|
if (onlyNew && (!primed || prevActiveSessionIds.has(s.id))) continue;
|
|
3755
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;
|
|
3756
4541
|
}
|
|
3757
4542
|
prevActiveSessionIds = activeIds;
|
|
3758
4543
|
if (changed) persistCollapsedGroups();
|
|
@@ -3760,7 +4545,11 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
3760
4545
|
}
|
|
3761
4546
|
|
|
3762
4547
|
function isGroupHeader(el) {
|
|
3763
|
-
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
|
+
);
|
|
3764
4553
|
}
|
|
3765
4554
|
|
|
3766
4555
|
function findParentHeader(el) {
|
|
@@ -3768,10 +4557,10 @@ function findParentHeader(el) {
|
|
|
3768
4557
|
if (subContainer?.previousElementSibling?.classList.contains('pinned-sub-header')) {
|
|
3769
4558
|
return subContainer.previousElementSibling;
|
|
3770
4559
|
}
|
|
3771
|
-
const container = el.closest('.project-group-sessions');
|
|
4560
|
+
const container = el.closest('.project-group-sessions, .session-group-sessions');
|
|
3772
4561
|
if (!container) return null;
|
|
3773
4562
|
let header = container.previousElementSibling;
|
|
3774
|
-
while (header && !
|
|
4563
|
+
while (header && !isGroupHeader(header)) header = header.previousElementSibling;
|
|
3775
4564
|
return header;
|
|
3776
4565
|
}
|
|
3777
4566
|
|
|
@@ -3785,6 +4574,12 @@ function handleSidebarHorizontal(direction) {
|
|
|
3785
4574
|
const isCollapsed = collapsedProjectGroups.has(el.dataset.groupPath);
|
|
3786
4575
|
if (collapse) {
|
|
3787
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
|
+
}
|
|
3788
4583
|
} else if (isCollapsed) {
|
|
3789
4584
|
setGroupCollapsed(el, false);
|
|
3790
4585
|
} else {
|
|
@@ -4682,13 +5477,14 @@ function _renderStorageLinkedDocs() {
|
|
|
4682
5477
|
const { groups, orphans } = _groupByProject(entries.map((e) => e.sessionId));
|
|
4683
5478
|
|
|
4684
5479
|
function renderDocRow(sessionId, p) {
|
|
4685
|
-
const name = p
|
|
5480
|
+
const name = linkedDocLabel(p);
|
|
4686
5481
|
const sid = escAttrJs(sessionId);
|
|
4687
5482
|
const jsPath = escAttrJs(p);
|
|
4688
5483
|
return `<div class="storage-item" style="padding-left:24px;">
|
|
4689
5484
|
<span class="storage-item-id" title="${escapeHtml(p)}">${escapeHtml(name)}</span>
|
|
4690
5485
|
<div class="storage-item-actions">
|
|
4691
5486
|
<button onclick="_storagePreviewLinkedDoc('${jsPath}')">View</button>
|
|
5487
|
+
<button onclick="copyWithFeedback('${jsPath}', this)" title="Copy path" aria-label="Copy path">${ICON_COPY}</button>
|
|
4692
5488
|
<button class="danger" onclick="_storageUnlinkDoc('${sid}','${jsPath}')">Unlink</button>
|
|
4693
5489
|
</div>
|
|
4694
5490
|
</div>`;
|
|
@@ -4725,7 +5521,7 @@ function _renderStorageLinkedDocs() {
|
|
|
4725
5521
|
}
|
|
4726
5522
|
|
|
4727
5523
|
function _storagePreviewLinkedDoc(path) {
|
|
4728
|
-
|
|
5524
|
+
openLinkedDoc(path);
|
|
4729
5525
|
}
|
|
4730
5526
|
|
|
4731
5527
|
function _storageUnlinkDoc(sessionId, path) {
|
|
@@ -5387,21 +6183,74 @@ function isPreviewablePath(p) {
|
|
|
5387
6183
|
return /\.(md|markdown|html?)$/i.test(p);
|
|
5388
6184
|
}
|
|
5389
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
|
+
|
|
5390
6236
|
function renderLinkedDocsHtml(sessionId) {
|
|
5391
6237
|
const paths = getSessionPreviewPaths(sessionId);
|
|
5392
6238
|
const baseDir = getSessionBaseDir(sessionId);
|
|
5393
6239
|
const items = paths
|
|
5394
6240
|
.map((p) => {
|
|
5395
|
-
const
|
|
6241
|
+
const opener = linkedDocOpener(p);
|
|
6242
|
+
const { tag, hint } = LINKED_DOC_OPENERS[opener];
|
|
6243
|
+
const name = linkedDocLabel(p);
|
|
5396
6244
|
const rel = baseDir ? toRelativeIfUnder(p, baseDir) : null;
|
|
5397
|
-
const previewable = isPreviewablePath(p);
|
|
5398
|
-
const title = previewable ? p : `${p} — opens in editor`;
|
|
5399
6245
|
const pathSpan = rel ? `<span class="linked-doc-path" title="${escapeHtml(p)}">${escapeHtml(rel)}</span>` : '';
|
|
5400
6246
|
const attr = escapeHtml(p);
|
|
5401
|
-
return `<li class="linked-doc-item${
|
|
5402
|
-
<a href="#" class="linked-doc-link" data-path="${attr}" title="${escapeHtml(
|
|
5403
|
-
${pathSpan}${
|
|
5404
|
-
<
|
|
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>
|
|
5405
6254
|
</li>`;
|
|
5406
6255
|
})
|
|
5407
6256
|
.join('');
|
|
@@ -5426,18 +6275,18 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
5426
6275
|
function bindLinkedDocsHandlers(container, sessionId) {
|
|
5427
6276
|
if (!container) return;
|
|
5428
6277
|
container.addEventListener('click', (e) => {
|
|
5429
|
-
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');
|
|
5430
6279
|
if (!hit) return;
|
|
5431
6280
|
e.preventDefault();
|
|
5432
6281
|
if (hit.classList.contains('linked-docs-add-btn')) {
|
|
5433
6282
|
startLinkedDocInput(container, sessionId);
|
|
6283
|
+
} else if (hit.classList.contains('linked-doc-copy')) {
|
|
6284
|
+
copyWithFeedback(hit.dataset.path, hit);
|
|
5434
6285
|
} else if (hit.classList.contains('linked-doc-remove')) {
|
|
5435
6286
|
removeSessionPreviewPath(sessionId, hit.dataset.path);
|
|
5436
6287
|
afterLinkedDocsChanged(sessionId);
|
|
5437
|
-
} else if (isPreviewablePath(hit.dataset.path)) {
|
|
5438
|
-
openPreviewByPath(hit.dataset.path, getSessionBaseDir(sessionId));
|
|
5439
6288
|
} else {
|
|
5440
|
-
|
|
6289
|
+
openLinkedDoc(hit.dataset.path, getSessionBaseDir(sessionId));
|
|
5441
6290
|
}
|
|
5442
6291
|
});
|
|
5443
6292
|
}
|
|
@@ -5445,7 +6294,7 @@ function bindLinkedDocsHandlers(container, sessionId) {
|
|
|
5445
6294
|
function startLinkedDocInput(container, sessionId) {
|
|
5446
6295
|
const slot = container.querySelector('.linked-doc-editor-slot');
|
|
5447
6296
|
if (!slot) return;
|
|
5448
|
-
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">
|
|
5449
6298
|
<div class="linked-doc-hint">${escapeHtml(getSessionBaseDir(sessionId) || 'no session cwd — absolute paths only')}</div>
|
|
5450
6299
|
<div class="linked-doc-error"></div>
|
|
5451
6300
|
<div class="edit-actions">
|
|
@@ -5822,25 +6671,179 @@ function isWaitingFresh() {
|
|
|
5822
6671
|
return Date.now() - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
|
|
5823
6672
|
}
|
|
5824
6673
|
|
|
6674
|
+
// Per-question selections for the AskUserQuestion form, keyed by question text
|
|
6675
|
+
// (the shape the tool's `answers` input expects). Picks hold arrays of option
|
|
6676
|
+
// labels (length <= 1 unless multiSelect); free text lives separately so a
|
|
6677
|
+
// multi-select can combine both, the way the TUI's "Other" row does. Reset
|
|
6678
|
+
// when the ask changes.
|
|
6679
|
+
let waitingAnswerDraft = {};
|
|
6680
|
+
let waitingCustomDraft = {};
|
|
6681
|
+
let waitingDraftId = null;
|
|
6682
|
+
let waitingQuestions = [];
|
|
6683
|
+
|
|
6684
|
+
function waitingAnswerFor(q) {
|
|
6685
|
+
const picks = waitingAnswerDraft[q.question] || [];
|
|
6686
|
+
const custom = waitingCustomDraft[q.question] || '';
|
|
6687
|
+
if (q.multiSelect) {
|
|
6688
|
+
const all = custom ? picks.concat(custom) : picks;
|
|
6689
|
+
return all.length ? all : null;
|
|
6690
|
+
}
|
|
6691
|
+
return picks[0] || custom || null;
|
|
6692
|
+
}
|
|
6693
|
+
|
|
6694
|
+
// The action buttons live in the modal's fixed footer (#msg-detail-waiting-footer),
|
|
6695
|
+
// not the scrollable body — long content must not push them out of reach.
|
|
6696
|
+
function renderWaitingFooter(tool, params) {
|
|
6697
|
+
if (currentWaiting?.kind === 'plan') {
|
|
6698
|
+
return isWaitingAnswerable() ? planApprovalControlsHtml('plan-reject-feedback') : '';
|
|
6699
|
+
}
|
|
6700
|
+
if (currentWaiting?.kind !== 'question') return waitingDecisionButtons('waiting-btn');
|
|
6701
|
+
if (!isWaitingAnswerable()) return '';
|
|
6702
|
+
if (tool !== 'AskUserQuestion') return '';
|
|
6703
|
+
const questions = Array.isArray(params?.questions) ? params.questions : [];
|
|
6704
|
+
if (!questions.length) return '';
|
|
6705
|
+
const picked = questions.filter((q) => waitingAnswerFor(q)).length;
|
|
6706
|
+
const partial = picked > 0 && picked < questions.length ? ` (${picked}/${questions.length})` : '';
|
|
6707
|
+
return `<button id="waiting-answer-btn" class="waiting-btn-allow" ${picked > 0 ? '' : 'disabled'} onclick="submitWaitingAnswers()">Answer${partial}</button>`;
|
|
6708
|
+
}
|
|
6709
|
+
|
|
6710
|
+
function renderWaitingActions(tool, params) {
|
|
6711
|
+
if (!isWaitingAnswerable() || currentWaiting.kind !== 'question' || tool !== 'AskUserQuestion') return '';
|
|
6712
|
+
waitingQuestions = Array.isArray(params?.questions) ? params.questions : [];
|
|
6713
|
+
if (!waitingQuestions.length) return '';
|
|
6714
|
+
const qs = waitingQuestions
|
|
6715
|
+
.map((q, idx) => {
|
|
6716
|
+
const picks = waitingAnswerDraft[q.question] || [];
|
|
6717
|
+
const opts = (q.options || [])
|
|
6718
|
+
.map((o) => {
|
|
6719
|
+
const label = typeof o === 'string' ? o : o?.label || '';
|
|
6720
|
+
const desc = typeof o === 'object' ? o?.description || '' : '';
|
|
6721
|
+
const descHtml = desc ? `<span class="waiting-option-desc">${renderMarkdown(desc)}</span>` : '';
|
|
6722
|
+
// The option `preview` field is the TUI's side-by-side visualization
|
|
6723
|
+
// pane (mockups, code snippets, diagrams) — rendered as markdown
|
|
6724
|
+
const preview = typeof o === 'object' ? o?.preview || '' : '';
|
|
6725
|
+
const previewHtml = preview ? `<span class="waiting-option-preview">${renderMarkdown(preview)}</span>` : '';
|
|
6726
|
+
return `<button class="waiting-option${picks.includes(label) ? ' selected' : ''}" data-label="${escapeHtml(label)}" onclick="selectWaitingAnswer(${idx}, this.dataset.label)"><span class="waiting-option-label">${escapeHtml(label)}</span>${descHtml}${previewHtml}</button>`;
|
|
6727
|
+
})
|
|
6728
|
+
.join('');
|
|
6729
|
+
const header = q.header ? `<span class="waiting-question-header">${escapeHtml(q.header)}</span>` : '';
|
|
6730
|
+
const multi = q.multiSelect ? '<span class="waiting-question-multi">multi-select</span>' : '';
|
|
6731
|
+
const input = `<input type="text" class="waiting-option-input" placeholder="Or type your own answer" value="${escapeHtml(waitingCustomDraft[q.question] || '')}" oninput="setWaitingCustomAnswer(${idx}, this)">`;
|
|
6732
|
+
return `<div class="waiting-question">${header}${multi}<div class="waiting-question-text">${renderMarkdown(q.question || '')}</div><div class="waiting-options">${opts}</div>${input}</div>`;
|
|
6733
|
+
})
|
|
6734
|
+
.join('');
|
|
6735
|
+
return `<div class="waiting-actions">${qs}</div>`;
|
|
6736
|
+
}
|
|
6737
|
+
|
|
6738
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
6739
|
+
function selectWaitingAnswer(qi, label) {
|
|
6740
|
+
const q = waitingQuestions[qi];
|
|
6741
|
+
if (!q) return;
|
|
6742
|
+
// Clicking a picked option unpicks it, in both modes
|
|
6743
|
+
const picks = waitingAnswerDraft[q.question] || [];
|
|
6744
|
+
if (q.multiSelect) {
|
|
6745
|
+
waitingAnswerDraft[q.question] = picks.includes(label) ? picks.filter((l) => l !== label) : picks.concat(label);
|
|
6746
|
+
} else if (picks.includes(label)) {
|
|
6747
|
+
delete waitingAnswerDraft[q.question];
|
|
6748
|
+
} else {
|
|
6749
|
+
waitingAnswerDraft[q.question] = [label];
|
|
6750
|
+
delete waitingCustomDraft[q.question];
|
|
6751
|
+
}
|
|
6752
|
+
showWaitingDetail();
|
|
6753
|
+
}
|
|
6754
|
+
|
|
6755
|
+
// Free-text path — the TUI's "type your own answer". Updates the draft and the
|
|
6756
|
+
// Answer button in place: a full re-render here would drop the input's focus
|
|
6757
|
+
// on every keystroke.
|
|
6758
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML oninput
|
|
6759
|
+
function setWaitingCustomAnswer(qi, el) {
|
|
6760
|
+
const q = waitingQuestions[qi];
|
|
6761
|
+
if (!q) return;
|
|
6762
|
+
const v = el.value.trim();
|
|
6763
|
+
if (v) waitingCustomDraft[q.question] = v;
|
|
6764
|
+
else delete waitingCustomDraft[q.question];
|
|
6765
|
+
if (v && !q.multiSelect) {
|
|
6766
|
+
delete waitingAnswerDraft[q.question];
|
|
6767
|
+
for (const b of el.closest('.waiting-question')?.querySelectorAll('.waiting-option.selected') || []) {
|
|
6768
|
+
b.classList.remove('selected');
|
|
6769
|
+
}
|
|
6770
|
+
}
|
|
6771
|
+
const btn = document.getElementById('waiting-answer-btn');
|
|
6772
|
+
if (btn) {
|
|
6773
|
+
const picked = waitingQuestions.filter((qq) => waitingAnswerFor(qq)).length;
|
|
6774
|
+
btn.disabled = picked === 0;
|
|
6775
|
+
btn.textContent =
|
|
6776
|
+
picked > 0 && picked < waitingQuestions.length ? `Answer (${picked}/${waitingQuestions.length})` : 'Answer';
|
|
6777
|
+
}
|
|
6778
|
+
}
|
|
6779
|
+
|
|
6780
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
6781
|
+
function submitWaitingAnswers() {
|
|
6782
|
+
// Partial submits are fine — send only the questions actually answered.
|
|
6783
|
+
// multiSelect answers go as arrays of labels (the shape the TUI validates).
|
|
6784
|
+
const answers = {};
|
|
6785
|
+
for (const q of waitingQuestions) {
|
|
6786
|
+
const a = waitingAnswerFor(q);
|
|
6787
|
+
if (a) answers[q.question] = a;
|
|
6788
|
+
}
|
|
6789
|
+
if (!Object.keys(answers).length) return;
|
|
6790
|
+
respondWaiting({ answers });
|
|
6791
|
+
}
|
|
6792
|
+
|
|
5825
6793
|
function showWaitingDetail() {
|
|
5826
6794
|
if (!isWaitingFresh()) return;
|
|
5827
6795
|
currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
|
|
5828
6796
|
msgHighlightDimmed = false;
|
|
5829
6797
|
highlightSelectedMsg();
|
|
6798
|
+
// Follow-mode poll ticks re-run this whole render — carry any typed reject
|
|
6799
|
+
// feedback across the rebuild, but never onto a different ask.
|
|
6800
|
+
const sameAsk = currentWaiting.id === waitingDraftId;
|
|
6801
|
+
const feedbackDraft = sameAsk ? document.getElementById('plan-reject-feedback')?.value || '' : '';
|
|
6802
|
+
if (!sameAsk) {
|
|
6803
|
+
waitingDraftId = currentWaiting.id || null;
|
|
6804
|
+
waitingAnswerDraft = {};
|
|
6805
|
+
waitingCustomDraft = {};
|
|
6806
|
+
}
|
|
5830
6807
|
const tool = currentWaiting.toolName || 'unknown';
|
|
5831
6808
|
const label = getWaitingLabel(currentWaiting.kind, tool);
|
|
5832
6809
|
const body = document.getElementById('msg-detail-body');
|
|
5833
6810
|
let inputHtml = '';
|
|
6811
|
+
const params = parseWaitingInput();
|
|
5834
6812
|
if (currentWaiting.toolInput) {
|
|
5835
|
-
|
|
5836
|
-
try {
|
|
5837
|
-
pretty = JSON.stringify(JSON.parse(currentWaiting.toolInput), null, 2);
|
|
5838
|
-
} catch (_) {
|
|
5839
|
-
/* keep raw */
|
|
5840
|
-
}
|
|
6813
|
+
const pretty = params ? JSON.stringify(params, null, 2) : currentWaiting.toolInput;
|
|
5841
6814
|
inputHtml = `<pre class="${TINTED_PRE_CLASS}">${escapeHtml(pretty)}</pre>`;
|
|
5842
6815
|
}
|
|
5843
|
-
|
|
6816
|
+
const actionsHtml = renderWaitingActions(tool, params);
|
|
6817
|
+
const footerHtml = renderWaitingFooter(tool, params);
|
|
6818
|
+
// With actions on screen the form/buttons are the content — tuck the raw
|
|
6819
|
+
// JSON behind a toggle, but keep what the user is approving visible: for a
|
|
6820
|
+
// permission ask surface the command (or description) as a one-line summary
|
|
6821
|
+
if ((actionsHtml || footerHtml) && inputHtml) {
|
|
6822
|
+
let summaryHtml = '';
|
|
6823
|
+
if (currentWaiting.kind === 'plan' && typeof params?.plan === 'string' && params.plan) {
|
|
6824
|
+
// A plan ask IS the plan — render it in full so it can be reviewed and
|
|
6825
|
+
// approved right here (follow mode included) instead of a JSON dump. The
|
|
6826
|
+
// raw tool input is dropped entirely to match the saved-plan modal.
|
|
6827
|
+
inputHtml = `<div class="detail-desc rendered-md">${renderMarkdown(params.plan)}</div>`;
|
|
6828
|
+
} else {
|
|
6829
|
+
if (currentWaiting.kind !== 'question' && params) {
|
|
6830
|
+
const gist =
|
|
6831
|
+
typeof params.command === 'string'
|
|
6832
|
+
? params.command
|
|
6833
|
+
: typeof params.description === 'string'
|
|
6834
|
+
? params.description
|
|
6835
|
+
: '';
|
|
6836
|
+
if (gist) summaryHtml = `<pre class="${TINTED_PRE_CLASS}">${escapeHtml(gist)}</pre>`;
|
|
6837
|
+
}
|
|
6838
|
+
inputHtml = `${summaryHtml}<details class="waiting-raw-input"><summary>Raw tool input</summary>${inputHtml}</details>`;
|
|
6839
|
+
}
|
|
6840
|
+
}
|
|
6841
|
+
body.innerHTML = inputHtml + actionsHtml;
|
|
6842
|
+
document.getElementById('msg-detail-waiting-footer').innerHTML = footerHtml;
|
|
6843
|
+
if (feedbackDraft) {
|
|
6844
|
+
const feedbackEl = document.getElementById('plan-reject-feedback');
|
|
6845
|
+
if (feedbackEl) feedbackEl.value = feedbackDraft;
|
|
6846
|
+
}
|
|
5844
6847
|
document.getElementById('msg-detail-title').textContent = label;
|
|
5845
6848
|
document.getElementById('msg-detail-agent-btn').style.display = 'none';
|
|
5846
6849
|
const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
|
|
@@ -5870,9 +6873,151 @@ function formatDate(dateStr) {
|
|
|
5870
6873
|
return date.toLocaleDateString();
|
|
5871
6874
|
}
|
|
5872
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
|
+
|
|
5873
6886
|
function stripAnsi(text) {
|
|
5874
|
-
|
|
5875
|
-
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;
|
|
5876
7021
|
}
|
|
5877
7022
|
|
|
5878
7023
|
function stripTeammateWrapper(text) {
|
|
@@ -6250,13 +7395,27 @@ document.addEventListener('click', (e) => {
|
|
|
6250
7395
|
return;
|
|
6251
7396
|
}
|
|
6252
7397
|
|
|
6253
|
-
const
|
|
6254
|
-
if (
|
|
6255
|
-
|
|
6256
|
-
|
|
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
|
+
}
|
|
6257
7416
|
}
|
|
6258
7417
|
|
|
6259
|
-
const header = e.target.closest('.project-group-header');
|
|
7418
|
+
const header = e.target.closest('.session-group-header, .pinned-sub-header, .project-group-header');
|
|
6260
7419
|
if (header) {
|
|
6261
7420
|
setGroupCollapsed(header, !collapsedProjectGroups.has(header.dataset.groupPath));
|
|
6262
7421
|
}
|
|
@@ -6716,7 +7875,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
6716
7875
|
const file = opts.openFile ? escapeHtml(opts.openFile) : '';
|
|
6717
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>`;
|
|
6718
7877
|
}
|
|
6719
|
-
html += `<span class="
|
|
7878
|
+
html += `<span class="row-actions">${copyBtn}${openBtn}</span>`;
|
|
6720
7879
|
});
|
|
6721
7880
|
html += `</div>`;
|
|
6722
7881
|
|
|
@@ -6979,7 +8138,7 @@ function renderGoalSubtitle(session) {
|
|
|
6979
8138
|
// Only active (unmet) goals reach here — a met goal auto-clears. Clicking
|
|
6980
8139
|
// opens the info modal (full text); stopPropagation so it doesn't also
|
|
6981
8140
|
// trigger the card's fetchTasks.
|
|
6982
|
-
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>`;
|
|
6983
8142
|
}
|
|
6984
8143
|
|
|
6985
8144
|
function renderLoopBadge(session) {
|
|
@@ -7217,6 +8376,15 @@ function openPlanModal() {
|
|
|
7217
8376
|
if (!_pendingPlanContent) return;
|
|
7218
8377
|
const body = document.getElementById('plan-modal-body');
|
|
7219
8378
|
body.innerHTML = renderMarkdown(_pendingPlanContent);
|
|
8379
|
+
// A saved plan opened while its approval ask is still pending (sidebar
|
|
8380
|
+
// click, 'p') is the same review moment — offer Approve/Reject here too.
|
|
8381
|
+
document.getElementById('plan-approval-footer')?.remove();
|
|
8382
|
+
if (currentWaiting?.kind === 'plan' && isWaitingAnswerable() && _planSessionId === currentSessionId) {
|
|
8383
|
+
const controls = document.createElement('div');
|
|
8384
|
+
controls.id = 'plan-approval-footer';
|
|
8385
|
+
controls.innerHTML = planApprovalControlsHtml('plan-modal-reject-feedback');
|
|
8386
|
+
document.querySelector('#plan-modal .modal-footer').prepend(controls);
|
|
8387
|
+
}
|
|
7220
8388
|
document.getElementById('plan-modal').classList.add('visible');
|
|
7221
8389
|
const keyHandler = (e) => {
|
|
7222
8390
|
if (e.key === 'Escape') {
|
|
@@ -7494,6 +8662,8 @@ try {
|
|
|
7494
8662
|
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
7495
8663
|
cg.forEach((p) => collapsedProjectGroups.add(p));
|
|
7496
8664
|
} catch (_) {}
|
|
8665
|
+
loadSessionGroups();
|
|
8666
|
+
initSessionGroupsDnd();
|
|
7497
8667
|
try {
|
|
7498
8668
|
const af = JSON.parse(localStorage.getItem('activityFilter') || '[]');
|
|
7499
8669
|
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|