clay-server 2.11.0 → 2.12.0-beta.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.
@@ -1240,38 +1240,34 @@ export function populateCliSessionList(sessions) {
1240
1240
  // --- Search hit timeline (right-side markers) ---
1241
1241
  var searchTimelineScrollHandler = null;
1242
1242
  var activeSearchQuery = ""; // query active in the timeline
1243
+ var pendingSearchScrollTarget = null; // { historyIndex, snippet, query } for scroll after history load
1243
1244
 
1244
1245
  export function getActiveSearchQuery() {
1245
1246
  return searchQuery;
1246
1247
  }
1247
1248
 
1249
+ // Request server-side content search for the active session
1248
1250
  export function buildSearchTimeline(query) {
1249
1251
  removeSearchTimeline();
1250
1252
  if (!query) return;
1251
1253
  activeSearchQuery = query;
1252
-
1253
- var q = query.toLowerCase();
1254
- var messagesEl = ctx.messagesEl;
1255
-
1256
- // Collect all message elements that contain the query
1257
- var allMsgs = messagesEl.querySelectorAll(".msg-user, .msg-assistant");
1258
- var hits = [];
1259
- for (var i = 0; i < allMsgs.length; i++) {
1260
- var msgEl = allMsgs[i];
1261
- var textEl = msgEl.querySelector(".bubble") || msgEl.querySelector(".md-content");
1262
- if (!textEl) continue;
1263
- var text = textEl.textContent || "";
1264
- if (text.toLowerCase().indexOf(q) === -1) continue;
1265
-
1266
- // Extract a snippet around the match
1267
- var idx = text.toLowerCase().indexOf(q);
1268
- var start = Math.max(0, idx - 10);
1269
- var end = Math.min(text.length, idx + query.length + 10);
1270
- var snippet = (start > 0 ? "\u2026" : "") + text.substring(start, end) + (end < text.length ? "\u2026" : "");
1271
- hits.push({ el: msgEl, snippet: snippet });
1254
+ // Request full-history search from server
1255
+ if (ctx.ws && ctx.connected) {
1256
+ ctx.ws.send(JSON.stringify({ type: "search_session_content", query: query }));
1272
1257
  }
1258
+ }
1259
+
1260
+ // Handle server response with full-history search results
1261
+ export function handleSearchContentResults(msg) {
1262
+ if (msg.query !== activeSearchQuery) return; // stale response
1263
+ var savedQuery = activeSearchQuery;
1264
+ removeSearchTimeline();
1265
+ if (!msg.hits || msg.hits.length === 0) return;
1266
+ activeSearchQuery = savedQuery;
1273
1267
 
1274
- if (hits.length === 0) return;
1268
+ var hits = msg.hits;
1269
+ var total = msg.total;
1270
+ var messagesEl = ctx.messagesEl;
1275
1271
 
1276
1272
  var timeline = document.createElement("div");
1277
1273
  timeline.className = "search-timeline";
@@ -1279,6 +1275,7 @@ export function buildSearchTimeline(query) {
1279
1275
 
1280
1276
  var track = document.createElement("div");
1281
1277
  track.className = "rewind-timeline-track";
1278
+ track.dataset.historyTotal = total;
1282
1279
 
1283
1280
  var viewport = document.createElement("div");
1284
1281
  viewport.className = "rewind-timeline-viewport";
@@ -1286,7 +1283,8 @@ export function buildSearchTimeline(query) {
1286
1283
 
1287
1284
  for (var i = 0; i < hits.length; i++) {
1288
1285
  var hit = hits[i];
1289
- var pct = hits.length === 1 ? 50 : 6 + (i / (hits.length - 1)) * 88;
1286
+ // Position based on historyIndex relative to total history length
1287
+ var pct = total <= 1 ? 50 : 6 + (hit.historyIndex / (total - 1)) * 88;
1290
1288
 
1291
1289
  var snippetText = hit.snippet;
1292
1290
  if (snippetText.length > 24) snippetText = snippetText.substring(0, 24) + "\u2026";
@@ -1295,16 +1293,14 @@ export function buildSearchTimeline(query) {
1295
1293
  marker.className = "rewind-timeline-marker search-hit-marker";
1296
1294
  marker.innerHTML = iconHtml("search") + '<span class="marker-text">' + escapeHtml(snippetText) + '</span>';
1297
1295
  marker.style.top = pct + "%";
1298
- marker.dataset.offsetTop = hit.el.offsetTop;
1296
+ // Store historyIndex for click handling and viewport check
1297
+ marker.dataset.historyIndex = hit.historyIndex;
1299
1298
 
1300
- (function(targetEl, markerEl) {
1299
+ (function(hitData, markerEl) {
1301
1300
  markerEl.addEventListener("click", function() {
1302
- targetEl.scrollIntoView({ behavior: "smooth", block: "center" });
1303
- targetEl.classList.remove("search-blink");
1304
- void targetEl.offsetWidth; // force reflow
1305
- targetEl.classList.add("search-blink");
1301
+ scrollToSearchHit(hitData.historyIndex, hitData.snippet, msg.query);
1306
1302
  });
1307
- })(hit.el, marker);
1303
+ })(hit, marker);
1308
1304
 
1309
1305
  track.appendChild(marker);
1310
1306
  }
@@ -1330,32 +1326,86 @@ export function buildSearchTimeline(query) {
1330
1326
  updateSearchTimelineViewport(track, viewport);
1331
1327
  }
1332
1328
 
1329
+ function scrollToSearchHit(historyIndex, snippet, query) {
1330
+ var historyFrom = ctx.getHistoryFrom ? ctx.getHistoryFrom() : 0;
1331
+ if (historyIndex < historyFrom) {
1332
+ // Need to load older history first
1333
+ pendingSearchScrollTarget = { historyIndex: historyIndex, snippet: snippet, query: query };
1334
+ if (ctx.ws && ctx.connected) {
1335
+ ctx.ws.send(JSON.stringify({ type: "load_more_history", before: historyFrom, target: historyIndex }));
1336
+ }
1337
+ return;
1338
+ }
1339
+ // History is loaded, find matching element in DOM
1340
+ findAndScrollToMatch(snippet, query);
1341
+ }
1342
+
1343
+ function findAndScrollToMatch(snippet, query) {
1344
+ var messagesEl = ctx.messagesEl;
1345
+ var q = query.toLowerCase();
1346
+ var allMsgs = messagesEl.querySelectorAll(".msg-user, .msg-assistant");
1347
+ for (var i = 0; i < allMsgs.length; i++) {
1348
+ var msgEl = allMsgs[i];
1349
+ var textEl = msgEl.querySelector(".bubble") || msgEl.querySelector(".md-content");
1350
+ if (!textEl) continue;
1351
+ var text = textEl.textContent || "";
1352
+ if (text.toLowerCase().indexOf(q) === -1) continue;
1353
+ // Check if the snippet content matches (strip ellipsis for comparison)
1354
+ var cleanSnippet = snippet.replace(/^\u2026/, "").replace(/\u2026$/, "");
1355
+ if (text.indexOf(cleanSnippet) !== -1) {
1356
+ msgEl.scrollIntoView({ behavior: "smooth", block: "center" });
1357
+ msgEl.classList.remove("search-blink");
1358
+ void msgEl.offsetWidth;
1359
+ msgEl.classList.add("search-blink");
1360
+ return;
1361
+ }
1362
+ }
1363
+ // Fallback: scroll to any element containing the query text
1364
+ for (var j = 0; j < allMsgs.length; j++) {
1365
+ var el = allMsgs[j];
1366
+ var tEl = el.querySelector(".bubble") || el.querySelector(".md-content");
1367
+ if (!tEl) continue;
1368
+ if ((tEl.textContent || "").toLowerCase().indexOf(q) !== -1) {
1369
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
1370
+ el.classList.remove("search-blink");
1371
+ void el.offsetWidth;
1372
+ el.classList.add("search-blink");
1373
+ return;
1374
+ }
1375
+ }
1376
+ }
1377
+
1378
+ // Called after history_prepend completes, to scroll to pending target
1379
+ export function onHistoryPrepended() {
1380
+ if (!pendingSearchScrollTarget) return;
1381
+ var target = pendingSearchScrollTarget;
1382
+ pendingSearchScrollTarget = null;
1383
+ requestAnimationFrame(function() {
1384
+ findAndScrollToMatch(target.snippet, target.query);
1385
+ });
1386
+ }
1387
+
1333
1388
  function updateSearchTimelineViewport(track, viewport) {
1334
1389
  if (!track) return;
1335
1390
  var messagesEl = ctx.messagesEl;
1336
1391
  var scrollH = messagesEl.scrollHeight;
1337
1392
  var viewH = messagesEl.clientHeight;
1338
- if (scrollH <= viewH) {
1339
- viewport.style.top = "0";
1340
- viewport.style.height = "100%";
1341
- } else {
1342
- var viewTop = messagesEl.scrollTop / scrollH;
1343
- var viewBot = (messagesEl.scrollTop + viewH) / scrollH;
1344
- viewport.style.top = (viewTop * 100) + "%";
1345
- viewport.style.height = ((viewBot - viewTop) * 100) + "%";
1346
- }
1347
1393
 
1348
- var markers = track.querySelectorAll(".search-hit-marker");
1349
- var vTop = messagesEl.scrollTop;
1350
- var vBot = vTop + viewH;
1394
+ // Map the visible scroll area to the timeline range (6% to 94%)
1395
+ var historyFrom = ctx.getHistoryFrom ? ctx.getHistoryFrom() : 0;
1396
+ var total = parseInt(track.dataset.historyTotal || "0", 10) || 1;
1397
+ var timelineStart = 6 + (historyFrom / (total - 1 || 1)) * 88;
1398
+ var timelineEnd = 94;
1399
+ var timelineRange = timelineEnd - timelineStart;
1351
1400
 
1352
- for (var i = 0; i < markers.length; i++) {
1353
- var msgTop = parseInt(markers[i].dataset.offsetTop, 10);
1354
- if (msgTop >= vTop && msgTop <= vBot) {
1355
- markers[i].classList.add("in-view");
1356
- } else {
1357
- markers[i].classList.remove("in-view");
1358
- }
1401
+ if (scrollH <= viewH) {
1402
+ viewport.style.top = timelineStart + "%";
1403
+ viewport.style.height = timelineRange + "%";
1404
+ } else {
1405
+ var scrollFrac = messagesEl.scrollTop / scrollH;
1406
+ var viewFrac = viewH / scrollH;
1407
+ viewport.style.top = (timelineStart + scrollFrac * timelineRange) + "%";
1408
+ viewport.style.height = (viewFrac * timelineRange) + "%";
1359
1409
  }
1360
1410
  }
1361
1411
 
@@ -1694,33 +1744,62 @@ function showIconCtxMenu(anchorEl, slug, name) {
1694
1744
  var menu = document.createElement("div");
1695
1745
  menu.className = "project-ctx-menu";
1696
1746
 
1697
- var iconItem = document.createElement("button");
1698
- iconItem.className = "project-ctx-item";
1699
- iconItem.innerHTML = iconHtml("smile") + " <span>Set Icon</span>";
1700
- iconItem.addEventListener("click", function (e) {
1701
- e.stopPropagation();
1702
- closeProjectCtxMenu();
1703
- showEmojiPicker(slug, anchorEl);
1704
- });
1705
- menu.appendChild(iconItem);
1747
+ var isWorktree = slug.indexOf("--") !== -1;
1706
1748
 
1707
- // --- Separator ---
1708
- var sep = document.createElement("div");
1709
- sep.className = "project-ctx-separator";
1710
- menu.appendChild(sep);
1749
+ if (isWorktree) {
1750
+ // Worktree context menu: only "Remove Worktree"
1751
+ var removeWtItem = document.createElement("button");
1752
+ removeWtItem.className = "project-ctx-item project-ctx-delete";
1753
+ removeWtItem.innerHTML = iconHtml("trash-2") + " <span>Remove Worktree</span>";
1754
+ removeWtItem.addEventListener("click", function (e) {
1755
+ e.stopPropagation();
1756
+ closeProjectCtxMenu();
1757
+ if (ctx.ws && ctx.connected) {
1758
+ ctx.ws.send(JSON.stringify({ type: "remove_project_check", slug: slug, name: name || slug }));
1759
+ }
1760
+ });
1761
+ menu.appendChild(removeWtItem);
1762
+ } else {
1763
+ // Regular project context menu
1764
+ var iconItem = document.createElement("button");
1765
+ iconItem.className = "project-ctx-item";
1766
+ iconItem.innerHTML = iconHtml("smile") + " <span>Set Icon</span>";
1767
+ iconItem.addEventListener("click", function (e) {
1768
+ e.stopPropagation();
1769
+ closeProjectCtxMenu();
1770
+ showEmojiPicker(slug, anchorEl);
1771
+ });
1772
+ menu.appendChild(iconItem);
1711
1773
 
1712
- // --- Remove Project ---
1713
- var removeItem = document.createElement("button");
1714
- removeItem.className = "project-ctx-item project-ctx-delete";
1715
- removeItem.innerHTML = iconHtml("trash-2") + " <span>Remove Project</span>";
1716
- removeItem.addEventListener("click", function (e) {
1717
- e.stopPropagation();
1718
- closeProjectCtxMenu();
1719
- if (ctx.ws && ctx.connected) {
1720
- ctx.ws.send(JSON.stringify({ type: "remove_project_check", slug: slug, name: name || slug }));
1721
- }
1722
- });
1723
- menu.appendChild(removeItem);
1774
+ // --- Add Worktree ---
1775
+ var wtItem = document.createElement("button");
1776
+ wtItem.className = "project-ctx-item";
1777
+ wtItem.innerHTML = iconHtml("git-branch") + " <span>Add Worktree</span>";
1778
+ wtItem.addEventListener("click", function (e) {
1779
+ e.stopPropagation();
1780
+ closeProjectCtxMenu();
1781
+ showWorktreeModal(slug, name || slug);
1782
+ });
1783
+ menu.appendChild(wtItem);
1784
+
1785
+ // --- Separator ---
1786
+ var sep = document.createElement("div");
1787
+ sep.className = "project-ctx-separator";
1788
+ menu.appendChild(sep);
1789
+
1790
+ // --- Remove Project ---
1791
+ var removeItem = document.createElement("button");
1792
+ removeItem.className = "project-ctx-item project-ctx-delete";
1793
+ removeItem.innerHTML = iconHtml("trash-2") + " <span>Remove Project</span>";
1794
+ removeItem.addEventListener("click", function (e) {
1795
+ e.stopPropagation();
1796
+ closeProjectCtxMenu();
1797
+ if (ctx.ws && ctx.connected) {
1798
+ ctx.ws.send(JSON.stringify({ type: "remove_project_check", slug: slug, name: name || slug }));
1799
+ }
1800
+ });
1801
+ menu.appendChild(removeItem);
1802
+ }
1724
1803
 
1725
1804
  document.body.appendChild(menu);
1726
1805
  projectCtxMenu = menu;
@@ -2176,8 +2255,227 @@ export function renderSidebarPresence(onlineUsers) {
2176
2255
  }
2177
2256
  }
2178
2257
 
2258
+ // --- Worktree folder collapse state (persisted in localStorage) ---
2259
+ var wtCollapsed = {};
2260
+ try {
2261
+ wtCollapsed = JSON.parse(localStorage.getItem("clay-wt-collapsed") || "{}");
2262
+ } catch (e) {}
2263
+ function setWtCollapsed(slug, collapsed) {
2264
+ wtCollapsed[slug] = collapsed;
2265
+ try { localStorage.setItem("clay-wt-collapsed", JSON.stringify(wtCollapsed)); } catch (e) {}
2266
+ }
2267
+
2268
+ // Group projects by parent/worktree relationship
2269
+ function groupProjects(projects) {
2270
+ var parents = [];
2271
+ var wtByParent = {};
2272
+ for (var i = 0; i < projects.length; i++) {
2273
+ var p = projects[i];
2274
+ if (p.isWorktree && p.parentSlug) {
2275
+ if (!wtByParent[p.parentSlug]) wtByParent[p.parentSlug] = [];
2276
+ wtByParent[p.parentSlug].push(p);
2277
+ } else {
2278
+ parents.push(p);
2279
+ }
2280
+ }
2281
+ return { parents: parents, wtByParent: wtByParent };
2282
+ }
2283
+
2284
+ // Create a standard icon-strip item element (shared between parent and worktree rendering)
2285
+ function createIconItem(p, currentSlug) {
2286
+ var el = document.createElement("a");
2287
+ var isActive = p.slug === currentSlug && !currentDmUserId;
2288
+ el.className = "icon-strip-item" + (isActive ? " active" : "");
2289
+ el.href = "/p/" + p.slug + "/";
2290
+ el.dataset.slug = p.slug;
2291
+
2292
+ if (p.icon) {
2293
+ var emojiSpan = document.createElement("span");
2294
+ emojiSpan.className = "project-emoji";
2295
+ emojiSpan.textContent = p.icon;
2296
+ parseEmojis(emojiSpan);
2297
+ el.appendChild(emojiSpan);
2298
+ } else {
2299
+ el.appendChild(document.createTextNode(getProjectAbbrev(p.name)));
2300
+ }
2301
+
2302
+ var pill = document.createElement("span");
2303
+ pill.className = "icon-strip-pill";
2304
+ el.appendChild(pill);
2305
+
2306
+ var statusDot = document.createElement("span");
2307
+ statusDot.className = "icon-strip-status";
2308
+ if (p.isProcessing) statusDot.classList.add("processing");
2309
+ el.appendChild(statusDot);
2310
+
2311
+ var projectBadge = document.createElement("span");
2312
+ projectBadge.className = "icon-strip-project-badge";
2313
+ if (p.unread > 0 && !isActive) {
2314
+ projectBadge.textContent = p.unread > 99 ? "99+" : String(p.unread);
2315
+ projectBadge.classList.add("has-unread");
2316
+ }
2317
+ el.appendChild(projectBadge);
2318
+
2319
+ (function (name, elem) {
2320
+ elem.addEventListener("mouseenter", function () { showIconTooltip(elem, name); });
2321
+ elem.addEventListener("mouseleave", hideIconTooltip);
2322
+ })(p.name, el);
2323
+
2324
+ (function (slug) {
2325
+ el.addEventListener("click", function (e) {
2326
+ e.preventDefault();
2327
+ if (ctx.switchProject) ctx.switchProject(slug);
2328
+ });
2329
+ })(p.slug);
2330
+
2331
+ return el;
2332
+ }
2333
+
2334
+ // Worktree creation modal
2335
+ function showWorktreeModal(parentSlug, parentName) {
2336
+ // Remove existing modal if any
2337
+ var existing = document.getElementById("wt-modal-container");
2338
+ if (existing) existing.remove();
2339
+
2340
+ var container = document.createElement("div");
2341
+ container.id = "wt-modal-container";
2342
+
2343
+ var overlay = document.createElement("div");
2344
+ overlay.className = "wt-modal-overlay";
2345
+ container.appendChild(overlay);
2346
+
2347
+ var modal = document.createElement("div");
2348
+ modal.className = "wt-modal";
2349
+
2350
+ var title = document.createElement("div");
2351
+ title.className = "wt-modal-title";
2352
+ title.textContent = "Add Worktree \u2014 " + parentName;
2353
+ modal.appendChild(title);
2354
+
2355
+ var branchLabel = document.createElement("label");
2356
+ branchLabel.className = "wt-modal-label";
2357
+ branchLabel.textContent = "Branch name";
2358
+ modal.appendChild(branchLabel);
2359
+
2360
+ var branchInput = document.createElement("input");
2361
+ branchInput.type = "text";
2362
+ branchInput.className = "wt-modal-input";
2363
+ branchInput.placeholder = "feat/my-feature";
2364
+ branchInput.autocomplete = "off";
2365
+ branchInput.spellcheck = false;
2366
+ modal.appendChild(branchInput);
2367
+
2368
+ var baseLabel = document.createElement("label");
2369
+ baseLabel.className = "wt-modal-label";
2370
+ baseLabel.textContent = "Base branch";
2371
+ modal.appendChild(baseLabel);
2372
+
2373
+ var baseSelect = document.createElement("select");
2374
+ baseSelect.className = "wt-modal-input";
2375
+ // Add "main" as default while loading
2376
+ var defaultOpt = document.createElement("option");
2377
+ defaultOpt.value = "main";
2378
+ defaultOpt.textContent = "main";
2379
+ baseSelect.appendChild(defaultOpt);
2380
+ modal.appendChild(baseSelect);
2381
+
2382
+ // Fetch branches from target project via HTTP API
2383
+ fetch("/p/" + parentSlug + "/api/branches")
2384
+ .then(function (res) { return res.json(); })
2385
+ .then(function (data) {
2386
+ baseSelect.innerHTML = "";
2387
+ var branches = data.branches || ["main"];
2388
+ var defBranch = data.defaultBranch || "main";
2389
+ for (var i = 0; i < branches.length; i++) {
2390
+ var opt = document.createElement("option");
2391
+ opt.value = branches[i];
2392
+ opt.textContent = branches[i];
2393
+ if (branches[i] === defBranch) opt.selected = true;
2394
+ baseSelect.appendChild(opt);
2395
+ }
2396
+ })
2397
+ .catch(function () {});
2398
+
2399
+ var errorDiv = document.createElement("div");
2400
+ errorDiv.className = "wt-modal-error";
2401
+ modal.appendChild(errorDiv);
2402
+
2403
+ var actions = document.createElement("div");
2404
+ actions.className = "wt-modal-actions";
2405
+
2406
+ var cancelBtn = document.createElement("button");
2407
+ cancelBtn.className = "wt-modal-btn";
2408
+ cancelBtn.textContent = "Cancel";
2409
+ actions.appendChild(cancelBtn);
2410
+
2411
+ var createBtn = document.createElement("button");
2412
+ createBtn.className = "wt-modal-btn primary";
2413
+ createBtn.textContent = "Create";
2414
+ actions.appendChild(createBtn);
2415
+
2416
+ modal.appendChild(actions);
2417
+ container.appendChild(modal);
2418
+ document.body.appendChild(container);
2419
+ branchInput.focus();
2420
+
2421
+ function closeModal() { container.remove(); }
2422
+
2423
+ function doCreate() {
2424
+ var branch = branchInput.value.trim();
2425
+ var base = baseSelect.value.trim() || null;
2426
+ if (!branch) {
2427
+ errorDiv.textContent = "Branch name is required";
2428
+ errorDiv.classList.add("visible");
2429
+ return;
2430
+ }
2431
+ // Sanitize: replace slashes with dashes for directory name
2432
+ var dirName = branch.replace(/\//g, "-");
2433
+ createBtn.disabled = true;
2434
+ createBtn.textContent = "Creating...";
2435
+ errorDiv.classList.remove("visible");
2436
+
2437
+ if (ctx.ws && ctx.connected) {
2438
+ ctx.ws.send(JSON.stringify({
2439
+ type: "create_worktree",
2440
+ branch: dirName,
2441
+ baseBranch: base
2442
+ }));
2443
+ }
2444
+
2445
+ // Listen for the result
2446
+ var handler = function (event) {
2447
+ var msg;
2448
+ try { msg = JSON.parse(event.data); } catch (e) { return; }
2449
+ if (msg.type === "create_worktree_result") {
2450
+ ctx.ws.removeEventListener("message", handler);
2451
+ if (msg.ok) {
2452
+ closeModal();
2453
+ if (msg.slug && ctx.switchProject) ctx.switchProject(msg.slug);
2454
+ } else {
2455
+ createBtn.disabled = false;
2456
+ createBtn.textContent = "Create";
2457
+ errorDiv.textContent = msg.error || "Failed to create worktree";
2458
+ errorDiv.classList.add("visible");
2459
+ }
2460
+ }
2461
+ };
2462
+ ctx.ws.addEventListener("message", handler);
2463
+ }
2464
+
2465
+ overlay.addEventListener("click", closeModal);
2466
+ cancelBtn.addEventListener("click", closeModal);
2467
+ createBtn.addEventListener("click", doCreate);
2468
+ branchInput.addEventListener("keydown", function (e) {
2469
+ if (e.key === "Enter") doCreate();
2470
+ if (e.key === "Escape") closeModal();
2471
+ });
2472
+ baseSelect.addEventListener("keydown", function (e) {
2473
+ if (e.key === "Enter") doCreate();
2474
+ if (e.key === "Escape") closeModal();
2475
+ });
2476
+ }
2477
+
2179
2478
  export function renderIconStrip(projects, currentSlug) {
2180
- // Cache for mobile sheet
2181
2479
  cachedProjectList = projects;
2182
2480
  cachedCurrentSlug = currentSlug;
2183
2481
 
@@ -2185,71 +2483,154 @@ export function renderIconStrip(projects, currentSlug) {
2185
2483
  if (!container) return;
2186
2484
  container.innerHTML = "";
2187
2485
 
2188
- for (var i = 0; i < projects.length; i++) {
2189
- var p = projects[i];
2190
- var el = document.createElement("a");
2191
- var isActive = p.slug === currentSlug && !currentDmUserId;
2192
- el.className = "icon-strip-item" + (isActive ? " active" : "");
2193
- el.href = "/p/" + p.slug + "/";
2194
- el.dataset.slug = p.slug;
2195
-
2196
- // Icon: twemoji or abbreviation
2197
- if (p.icon) {
2198
- var emojiSpan = document.createElement("span");
2199
- emojiSpan.className = "project-emoji";
2200
- emojiSpan.textContent = p.icon;
2201
- parseEmojis(emojiSpan);
2202
- el.appendChild(emojiSpan);
2203
- } else {
2204
- el.appendChild(document.createTextNode(getProjectAbbrev(p.name)));
2205
- }
2206
-
2207
- var pill = document.createElement("span");
2208
- pill.className = "icon-strip-pill";
2209
- el.appendChild(pill);
2486
+ var grouped = groupProjects(projects);
2210
2487
 
2211
- // Socket status indicator dot (bottom-right)
2212
- var statusDot = document.createElement("span");
2213
- statusDot.className = "icon-strip-status";
2214
- if (p.isProcessing) statusDot.classList.add("processing");
2215
- el.appendChild(statusDot);
2488
+ for (var i = 0; i < grouped.parents.length; i++) {
2489
+ var p = grouped.parents[i];
2490
+ var worktrees = grouped.wtByParent[p.slug] || [];
2491
+ var hasWorktrees = worktrees.length > 0;
2216
2492
 
2217
- // Unread badge (top-right)
2218
- var projectBadge = document.createElement("span");
2219
- projectBadge.className = "icon-strip-project-badge";
2220
- if (p.unread > 0 && !isActive) {
2221
- projectBadge.textContent = p.unread > 99 ? "99+" : String(p.unread);
2222
- projectBadge.classList.add("has-unread");
2493
+ if (!hasWorktrees) {
2494
+ // Regular project, render as before
2495
+ var el = createIconItem(p, currentSlug);
2496
+ (function (slug, name, elem) {
2497
+ elem.addEventListener("contextmenu", function (e) {
2498
+ e.preventDefault();
2499
+ e.stopPropagation();
2500
+ showIconCtxMenu(elem, slug, name);
2501
+ });
2502
+ })(p.slug, p.name || p.slug, el);
2503
+ setupDragHandlers(el, p.slug);
2504
+ container.appendChild(el);
2505
+ continue;
2223
2506
  }
2224
- el.appendChild(projectBadge);
2225
2507
 
2226
- // Tooltip on hover
2227
- (function (name, elem) {
2228
- elem.addEventListener("mouseenter", function () { showIconTooltip(elem, name); });
2229
- elem.addEventListener("mouseleave", hideIconTooltip);
2230
- })(p.name, el);
2508
+ // Folder group for parent + worktrees
2509
+ var folder = document.createElement("div");
2510
+ folder.className = "icon-strip-group";
2511
+ folder.dataset.parentSlug = p.slug;
2512
+ if (wtCollapsed[p.slug]) folder.classList.add("collapsed");
2231
2513
 
2232
- // Click handler switch to project (no reload)
2233
- (function (slug) {
2234
- el.addEventListener("click", function (e) {
2235
- e.preventDefault();
2236
- if (ctx.switchProject) ctx.switchProject(slug);
2237
- });
2238
- })(p.slug);
2514
+ // Bubble up worktree processing state to parent
2515
+ if (!p.isProcessing) {
2516
+ for (var wpi = 0; wpi < worktrees.length; wpi++) {
2517
+ if (worktrees[wpi].isProcessing) { p.isProcessing = true; break; }
2518
+ }
2519
+ }
2239
2520
 
2240
- // Right-click context menu (icon only)
2521
+ // Parent icon as folder header
2522
+ var header = createIconItem(p, currentSlug);
2523
+ header.classList.add("folder-header");
2241
2524
  (function (slug, name, elem) {
2242
2525
  elem.addEventListener("contextmenu", function (e) {
2243
2526
  e.preventDefault();
2244
2527
  e.stopPropagation();
2245
2528
  showIconCtxMenu(elem, slug, name);
2246
2529
  });
2247
- })(p.slug, p.name || p.slug, el);
2530
+ })(p.slug, p.name || p.slug, header);
2531
+ setupDragHandlers(header, p.slug);
2532
+
2533
+ // Chevron toggle
2534
+ var chevron = document.createElement("span");
2535
+ chevron.className = "icon-strip-group-chevron";
2536
+ chevron.innerHTML = '<i data-lucide="git-branch"></i>';
2537
+ (function (parentSlug, folderEl) {
2538
+ chevron.addEventListener("click", function (e) {
2539
+ e.preventDefault();
2540
+ e.stopPropagation();
2541
+ var nowCollapsed = folderEl.classList.toggle("collapsed");
2542
+ setWtCollapsed(parentSlug, nowCollapsed);
2543
+ });
2544
+ chevron.addEventListener("contextmenu", function (e) {
2545
+ e.preventDefault();
2546
+ e.stopPropagation();
2547
+ });
2548
+ })(p.slug, folder);
2549
+ chevron.setAttribute("data-tip", "Toggle worktrees");
2550
+ header.appendChild(chevron);
2551
+ folder.appendChild(header);
2552
+
2553
+ // Worktree items container
2554
+ var itemsContainer = document.createElement("div");
2555
+ itemsContainer.className = "icon-strip-group-items";
2556
+
2557
+ for (var wi = 0; wi < worktrees.length; wi++) {
2558
+ (function (wt) {
2559
+ var wtEl = document.createElement("a");
2560
+ var isWtActive = wt.slug === currentSlug && !currentDmUserId;
2561
+ var isAccessible = wt.worktreeAccessible !== false;
2562
+ wtEl.className = "icon-strip-wt-item" + (isWtActive ? " active" : "") + (!isAccessible ? " wt-disabled" : "");
2563
+ wtEl.href = "/p/" + wt.slug + "/";
2564
+ wtEl.dataset.slug = wt.slug;
2565
+
2566
+ var abbrev = document.createElement("span");
2567
+ abbrev.className = "wt-branch-abbrev";
2568
+ abbrev.textContent = getProjectAbbrev(wt.name);
2569
+ wtEl.appendChild(abbrev);
2570
+
2571
+ var wtStatus = document.createElement("span");
2572
+ wtStatus.className = "icon-strip-status";
2573
+ if (wt.isProcessing) wtStatus.classList.add("processing");
2574
+ wtEl.appendChild(wtStatus);
2575
+
2576
+ var tooltipText = wt.name;
2577
+ if (!isAccessible) {
2578
+ tooltipText += " (outside project path, cannot be accessed)";
2579
+ }
2580
+ (function (text, elem) {
2581
+ elem.addEventListener("mouseenter", function () { showIconTooltip(elem, text); });
2582
+ elem.addEventListener("mouseleave", hideIconTooltip);
2583
+ })(tooltipText, wtEl);
2584
+
2585
+ if (isAccessible) {
2586
+ (function (slug) {
2587
+ wtEl.addEventListener("click", function (e) {
2588
+ e.preventDefault();
2589
+ if (ctx.switchProject) ctx.switchProject(slug);
2590
+ });
2591
+ })(wt.slug);
2592
+ } else {
2593
+ wtEl.addEventListener("click", function (e) {
2594
+ e.preventDefault();
2595
+ });
2596
+ }
2248
2597
 
2249
- // Drag-and-drop reordering
2250
- setupDragHandlers(el, p.slug);
2598
+ if (isAccessible) {
2599
+ (function (slug, name, elem) {
2600
+ elem.addEventListener("contextmenu", function (e) {
2601
+ e.preventDefault();
2602
+ e.stopPropagation();
2603
+ showIconCtxMenu(elem, slug, name);
2604
+ });
2605
+ })(wt.slug, wt.name, wtEl);
2606
+ } else {
2607
+ wtEl.addEventListener("contextmenu", function (e) {
2608
+ e.preventDefault();
2609
+ e.stopPropagation();
2610
+ });
2611
+ }
2612
+
2613
+ itemsContainer.appendChild(wtEl);
2614
+ })(worktrees[wi]);
2615
+ }
2616
+
2617
+ // "+" button to add new worktree
2618
+ var addBtn = document.createElement("button");
2619
+ addBtn.className = "icon-strip-group-add";
2620
+ addBtn.textContent = "+";
2621
+ (function (parentSlug, parentName, btn) {
2622
+ btn.addEventListener("click", function (e) {
2623
+ e.preventDefault();
2624
+ e.stopPropagation();
2625
+ showWorktreeModal(parentSlug, parentName);
2626
+ });
2627
+ btn.addEventListener("mouseenter", function () { showIconTooltip(btn, "New worktree"); });
2628
+ btn.addEventListener("mouseleave", hideIconTooltip);
2629
+ })(p.slug, p.name, addBtn);
2630
+ itemsContainer.appendChild(addBtn);
2251
2631
 
2252
- container.appendChild(el);
2632
+ folder.appendChild(itemsContainer);
2633
+ container.appendChild(folder);
2253
2634
  }
2254
2635
 
2255
2636
  // Update home icon active state
@@ -2262,8 +2643,10 @@ export function renderIconStrip(projects, currentSlug) {
2262
2643
  }
2263
2644
  }
2264
2645
 
2265
- // Also update mobile project list
2266
2646
  renderProjectList(projects, currentSlug);
2647
+
2648
+ // Render Lucide icons added dynamically (e.g. worktree git-branch icon)
2649
+ try { lucide.createIcons({ nodes: [container] }); } catch (e) {}
2267
2650
  }
2268
2651
 
2269
2652
  function renderProjectList(projects, currentSlug) {
@@ -2271,35 +2654,80 @@ function renderProjectList(projects, currentSlug) {
2271
2654
  if (!list) return;
2272
2655
  list.innerHTML = "";
2273
2656
 
2274
- for (var i = 0; i < projects.length; i++) {
2275
- (function (p) {
2276
- var el = document.createElement("button");
2277
- el.className = "mobile-project-item" + (p.slug === currentSlug ? " active" : "");
2657
+ var grouped = groupProjects(projects);
2278
2658
 
2279
- var abbrev = document.createElement("span");
2280
- abbrev.className = "mobile-project-abbrev";
2281
- abbrev.textContent = getProjectAbbrev(p.name);
2282
- el.appendChild(abbrev);
2659
+ for (var i = 0; i < grouped.parents.length; i++) {
2660
+ var p = grouped.parents[i];
2661
+ var worktrees = grouped.wtByParent[p.slug] || [];
2283
2662
 
2284
- var name = document.createElement("span");
2285
- name.className = "mobile-project-name";
2286
- name.textContent = p.name;
2287
- el.appendChild(name);
2663
+ if (worktrees.length === 0) {
2664
+ // Regular project
2665
+ list.appendChild(createMobileProjectItem(p, currentSlug, false));
2666
+ continue;
2667
+ }
2288
2668
 
2289
- if (p.isProcessing) {
2290
- var dot = document.createElement("span");
2291
- dot.className = "mobile-project-processing";
2292
- el.appendChild(dot);
2669
+ // Folder for parent + worktrees
2670
+ var folderDiv = document.createElement("div");
2671
+ folderDiv.className = "mobile-project-folder";
2672
+ if (wtCollapsed[p.slug]) folderDiv.classList.add("collapsed");
2673
+
2674
+ var headerEl = createMobileProjectItem(p, currentSlug, false);
2675
+ var chevron = document.createElement("span");
2676
+ chevron.className = "mobile-folder-chevron";
2677
+ chevron.innerHTML = "&#9660;";
2678
+ (function (parentSlug, fDiv) {
2679
+ chevron.addEventListener("click", function (e) {
2680
+ e.preventDefault();
2681
+ e.stopPropagation();
2682
+ var nowCollapsed = fDiv.classList.toggle("collapsed");
2683
+ setWtCollapsed(parentSlug, nowCollapsed);
2684
+ });
2685
+ })(p.slug, folderDiv);
2686
+ headerEl.appendChild(chevron);
2687
+ folderDiv.appendChild(headerEl);
2688
+
2689
+ var wtList = document.createElement("div");
2690
+ wtList.className = "mobile-folder-items";
2691
+ for (var wi = 0; wi < worktrees.length; wi++) {
2692
+ var isAccessible = worktrees[wi].worktreeAccessible !== false;
2693
+ var wtItem = createMobileProjectItem(worktrees[wi], currentSlug, true);
2694
+ if (!isAccessible) wtItem.classList.add("wt-disabled");
2695
+ if (!isAccessible) {
2696
+ wtItem.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); });
2293
2697
  }
2698
+ wtList.appendChild(wtItem);
2699
+ }
2700
+ folderDiv.appendChild(wtList);
2701
+ list.appendChild(folderDiv);
2702
+ }
2703
+ }
2294
2704
 
2295
- el.addEventListener("click", function () {
2296
- if (ctx.switchProject) ctx.switchProject(p.slug);
2297
- closeSidebar();
2298
- });
2705
+ function createMobileProjectItem(p, currentSlug, isWorktree) {
2706
+ var el = document.createElement("button");
2707
+ el.className = "mobile-project-item" + (p.slug === currentSlug ? " active" : "") + (isWorktree ? " wt-item" : "");
2708
+
2709
+ var abbrev = document.createElement("span");
2710
+ abbrev.className = "mobile-project-abbrev";
2711
+ abbrev.textContent = getProjectAbbrev(p.name);
2712
+ el.appendChild(abbrev);
2299
2713
 
2300
- list.appendChild(el);
2301
- })(projects[i]);
2714
+ var name = document.createElement("span");
2715
+ name.className = "mobile-project-name";
2716
+ name.textContent = p.name;
2717
+ el.appendChild(name);
2718
+
2719
+ if (p.isProcessing) {
2720
+ var dot = document.createElement("span");
2721
+ dot.className = "mobile-project-processing";
2722
+ el.appendChild(dot);
2302
2723
  }
2724
+
2725
+ el.addEventListener("click", function () {
2726
+ if (ctx.switchProject) ctx.switchProject(p.slug);
2727
+ closeSidebar();
2728
+ });
2729
+
2730
+ return el;
2303
2731
  }
2304
2732
 
2305
2733
  export function getEmojiCategories() { return EMOJI_CATEGORIES; }