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/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
- function getWaitingLabel(kind, tool) {
1184
- if (kind !== 'question') return `Awaiting permission: ${tool}`;
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 === 'question' && tool === 'ExitPlanMode') return 'Plan awaiting approval';
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
- let params = null;
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
- 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-time">waiting…</div></div>${discardBtn}</div>`;
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 ? escapeHtml(ref.preview) : '<em>(no text)</em>';
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">${escapeHtml(toolResult.trim())}</div>`;
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
- const desired = hasTable ? 1100 : body.textContent.length > 2000 || hasPre ? 960 : 860;
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 stripped = stripLineNumbers(toolResult);
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, escapeHtml(stripLineNumbers(fullResult)));
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.textContent = stripLineNumbers(content);
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 ? '&#x25B4;' : '&#x25BE;';
2631
2728
 
2632
2729
  const permHtml = permFresh
2633
- ? `<div class="permission-badge">${currentWaiting.kind === 'question' ? '❓ Question pending' : `⏳ Awaiting: ${escapeHtml(currentWaiting.toolName || 'unknown')}`}</div>`
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">&times;</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 filteredSessions) {
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">&times;</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
- const currentPaths = new Set(groups.keys());
3158
- const knownPaths = new Set(stableGroupOrder);
3159
- const keptOrder = stableGroupOrder.filter((p) => currentPaths.has(p));
3160
- const newPaths = [...currentPaths].filter((p) => !knownPaths.has(p));
3161
- if (newPaths.length > 1) {
3162
- const maxTime = new Map(
3163
- newPaths.map((p) => [p, Math.max(...groups.get(p).map((s) => new Date(s.modifiedAt).getTime()))]),
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
- let html = '';
3171
- if (!groupPinned && pinnedSessionIds.size > 0) {
3172
- const hasPinnedInView = filteredSessions.some((s) => pinnedSessionIds.has(s.id));
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
- const folderName = projectPath.split(/[/\\]/).pop();
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
- <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>
3401
+ ${groupChevronSvg()}
3215
3402
  <span class="group-name">Ungrouped</span>
3216
- <span class="group-count" title="${ungroupedActive} active / ${ungrouped.length} total">${ungroupedActive > 0 ? `<span class="group-count-active">${ungroupedActive}</span><span class="group-count-sep">/</span>` : ''}${ungrouped.length}</span>
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
- sessionsList.innerHTML = filteredSessions.map(renderSessionCard).join('');
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
- pendingTasks.innerHTML =
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
- inProgressTasks.innerHTML =
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
- completedTasks.innerHTML =
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)">&times;</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') ? 'pinned-sub-items' : 'project-group-sessions';
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 el.classList.contains('project-group-header') || el.classList.contains('pinned-sub-header');
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 && !header.classList.contains('project-group-header')) header = header.previousElementSibling;
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.split(/[\\/]/).pop();
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
- openPreviewByPath(path);
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 name = p.split(/[\\/]/).pop();
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${previewable ? '' : ' is-editor'}">
5402
- <a href="#" class="linked-doc-link" data-path="${attr}" title="${escapeHtml(title)}">${escapeHtml(name)}</a>
5403
- ${pathSpan}${previewable ? '' : '<span class="linked-doc-path">(editor)</span>'}
5404
- <button type="button" class="linked-doc-remove" data-path="${attr}" title="Unlink" aria-label="Unlink ${escapeHtml(name)}">&times;</button>
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)}">&times;</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
- postAndToast('/api/open-in-editor', { file: hit.dataset.path }, 'in editor');
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
- let pretty = currentWaiting.toolInput;
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
- body.innerHTML = inputHtml;
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
- // biome-ignore lint/suspicious/noControlCharactersInRegex: \x1b is intentional for ANSI escape sequence stripping
5875
- return typeof text === 'string' ? text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') : text;
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 pinnedSubHeader = e.target.closest('.pinned-sub-header');
6254
- if (pinnedSubHeader) {
6255
- setGroupCollapsed(pinnedSubHeader, !collapsedProjectGroups.has(pinnedSubHeader.dataset.groupPath));
6256
- return;
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="info-row-actions">${copyBtn}${openBtn}</span>`;
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