claude-code-kanban 4.21.0 → 4.23.1

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