clay-server 4.3.0-beta.1 → 4.3.0-beta.10

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.
Files changed (67) hide show
  1. package/lib/driver-continuation-pair.js +5 -1
  2. package/lib/multi-worker-feature.js +11 -0
  3. package/lib/project-connection.js +3 -1
  4. package/lib/project-message-delivery.js +20 -0
  5. package/lib/project-pair-autonomous-stop.js +14 -8
  6. package/lib/project-pair-close-transaction.js +59 -0
  7. package/lib/project-pair-global-stop.js +18 -0
  8. package/lib/project-pair-lifecycle-status.js +34 -0
  9. package/lib/project-pair-lifecycle.js +58 -76
  10. package/lib/project-pair-message.js +1 -1
  11. package/lib/project-pair-owned-stop.js +7 -4
  12. package/lib/project-pair-result-capture.js +116 -0
  13. package/lib/project-pair-result-delivery.js +140 -0
  14. package/lib/project-pair-result-outbox.js +400 -0
  15. package/lib/project-pair-result-pipeline.js +16 -0
  16. package/lib/project-pair-result-recovery.js +229 -0
  17. package/lib/project-pair-structural-close.js +20 -0
  18. package/lib/project-pair-task-control.js +45 -18
  19. package/lib/project-pair-worker-close.js +68 -0
  20. package/lib/project-session-pair-prompt.js +26 -0
  21. package/lib/project-session-pair.js +87 -106
  22. package/lib/project-sessions.js +4 -0
  23. package/lib/project-user-message.js +7 -0
  24. package/lib/project-worker-creation-proposal.js +176 -0
  25. package/lib/project-worker-permission.js +7 -8
  26. package/lib/project-worker-proposal-runner.js +45 -0
  27. package/lib/project-worker-proposal.js +79 -115
  28. package/lib/project.js +38 -1
  29. package/lib/public/app.js +1 -0
  30. package/lib/public/css/messages.css +12 -0
  31. package/lib/public/css/mobile-nav.css +29 -14
  32. package/lib/public/css/overlays.css +22 -21
  33. package/lib/public/css/pane.css +64 -14
  34. package/lib/public/css/sidebar.css +62 -7
  35. package/lib/public/index.html +3 -3
  36. package/lib/public/modules/app-connection.js +165 -74
  37. package/lib/public/modules/app-messages.js +17 -3
  38. package/lib/public/modules/message-delivery-ui.js +63 -0
  39. package/lib/public/modules/message-delivery.js +163 -6
  40. package/lib/public/modules/pair-result-status.js +87 -0
  41. package/lib/public/modules/permission-control.js +5 -5
  42. package/lib/public/modules/project-activation.js +10 -7
  43. package/lib/public/modules/session-creation-cta.js +46 -0
  44. package/lib/public/modules/sidebar-mobile.js +42 -30
  45. package/lib/public/modules/sidebar-sessions.js +77 -52
  46. package/lib/public/modules/split-group-helpers.js +74 -2
  47. package/lib/public/modules/split-pair-ui.js +28 -6
  48. package/lib/public/modules/split-pane-reconciler.js +28 -0
  49. package/lib/public/modules/split-pane-renderer.js +107 -0
  50. package/lib/public/modules/split-view.js +58 -188
  51. package/lib/public/modules/websocket-lifecycle.js +178 -0
  52. package/lib/public/modules/websocket-watchdog.js +126 -0
  53. package/lib/public/modules/worker-pane-lock.js +13 -3
  54. package/lib/server.js +1 -0
  55. package/lib/session-pair-factory.js +66 -0
  56. package/lib/session-pair-mcp-server.js +10 -5
  57. package/lib/session-pair-prompts.js +41 -25
  58. package/lib/session-pair-target.js +42 -0
  59. package/lib/session-pair-turn-control.js +11 -6
  60. package/lib/session-split-group-anchors.js +89 -7
  61. package/lib/session-split-group-persistence.js +136 -0
  62. package/lib/session-split-group-roles.js +164 -0
  63. package/lib/session-split-group-v2-store.js +146 -0
  64. package/lib/session-split-groups.js +109 -99
  65. package/lib/sessions.js +70 -21
  66. package/lib/ws-schema.js +2 -0
  67. package/package.json +1 -1
@@ -16,6 +16,7 @@ import {
16
16
  } from './sidebar-sessions.js';
17
17
  import { VENDOR_AVATARS, VENDOR_NAMES, VENDOR_ORDER, VENDOR_HOMEPAGES, isExperimentalVendor } from './app-rendering.js';
18
18
  import { defaultVendorUnavailable, resolvePreferredVendor, saveDefaultVendor } from './default-vendor.js';
19
+ import { renderSessionCreationCta } from './session-creation-cta.js';
19
20
  import {
20
21
  getCachedProjectList,
21
22
  getCachedCurrentSlug,
@@ -671,42 +672,50 @@ function createMobileLoopGroup(loopId, children, groupKey) {
671
672
  }
672
673
 
673
674
  // Helper: render sorted sessions into a container with date groups (with loop session grouping)
674
- function renderMobileSessionsInto(container) {
675
- // Mirrors the desktop sidebar's split button: one "New session" action on
676
- // the project's last-used vendor, plus a chevron that expands the full
677
- // vendor list. The list expands inline rather than as a fixed-position
678
- // popover because it lives inside a scrolling bottom sheet.
675
+ export function renderMobileSessionsInto(container) {
679
676
  var mobileDefaultVendor = resolvePreferredVendor(store.get('installedVendors') || []) || resolveDefaultVendor();
680
677
  var mobileInstalled = store.get('installedVendors') || [];
681
678
  var mobileDefaultState = store.get('defaultVendorState') || {};
682
679
 
683
- var newRow = document.createElement("div");
684
- newRow.className = "mobile-session-new-row";
685
-
686
- var mainBtn = document.createElement("button");
687
- mainBtn.className = "mobile-session-new mobile-session-new-main";
688
- mainBtn.title = "New " + (VENDOR_NAMES[mobileDefaultVendor] || mobileDefaultVendor) + " session";
689
- if (defaultVendorUnavailable(mobileInstalled)) mainBtn.title += " (saved default is unavailable; using installed priority)";
690
- mainBtn.innerHTML = '<img src="' + (VENDOR_AVATARS[mobileDefaultVendor] || VENDOR_AVATARS.claude) +
691
- '" class="mobile-session-new-icon" alt=""><span>New ' + (VENDOR_NAMES[mobileDefaultVendor] || mobileDefaultVendor) + ' session</span>';
692
- mainBtn.addEventListener("click", function () {
693
- startNewSession(mobileDefaultVendor);
694
- closeMobileSheet();
695
- });
696
- newRow.appendChild(mainBtn);
697
-
698
680
  var vendorList = document.createElement("div");
699
681
  vendorList.className = "mobile-vendor-list hidden";
700
-
701
- var chevronBtn = document.createElement("button");
702
- chevronBtn.className = "mobile-session-new mobile-session-new-chevron";
703
- chevronBtn.setAttribute("aria-label", "Choose a vendor");
704
- chevronBtn.innerHTML = iconHtml("chevron-down");
705
- chevronBtn.addEventListener("click", function () {
706
- vendorList.classList.toggle("hidden");
707
- chevronBtn.classList.toggle("expanded");
682
+ vendorList.id = "mobile-session-provider-menu";
683
+ var mobileVendorName = VENDOR_NAMES[mobileDefaultVendor] || mobileDefaultVendor;
684
+ var mobileCreateTitle = "Create a " + mobileVendorName + " session";
685
+ if (defaultVendorUnavailable(mobileInstalled)) mobileCreateTitle += " (saved default is unavailable; using installed priority)";
686
+ var controls;
687
+ controls = renderSessionCreationCta({
688
+ rowClass: "mobile-session-new-row",
689
+ createClass: "mobile-session-new mobile-session-new-main",
690
+ providerClass: "mobile-session-new mobile-session-new-provider",
691
+ iconClass: "mobile-session-new-icon",
692
+ vendorKey: mobileDefaultVendor,
693
+ vendorName: mobileVendorName,
694
+ vendorAvatar: VENDOR_AVATARS[mobileDefaultVendor] || VENDOR_AVATARS.claude,
695
+ createTitle: mobileCreateTitle,
696
+ providerTitle: "Switch AI provider (current: " + mobileVendorName + ")",
697
+ menuId: vendorList.id,
698
+ onCreate: function () { startNewSession(mobileDefaultVendor); closeMobileSheet(); },
699
+ onProvider: function () {
700
+ var opening = vendorList.classList.contains("hidden");
701
+ vendorList.classList.toggle("hidden", !opening);
702
+ controls.providerButton.classList.toggle("expanded", opening);
703
+ controls.providerButton.setAttribute("aria-expanded", opening ? "true" : "false");
704
+ if (opening) {
705
+ var first = vendorList.querySelector("button:not(:disabled)");
706
+ if (first) first.focus();
707
+ }
708
+ },
709
+ });
710
+ var newRow = controls.element;
711
+ vendorList.addEventListener("keydown", function (e) {
712
+ if (e.key !== "Escape") return;
713
+ e.preventDefault();
714
+ vendorList.classList.add("hidden");
715
+ controls.providerButton.classList.remove("expanded");
716
+ controls.providerButton.setAttribute("aria-expanded", "false");
717
+ controls.providerButton.focus();
708
718
  });
709
- newRow.appendChild(chevronBtn);
710
719
 
711
720
  if (defaultVendorUnavailable(mobileInstalled) && mobileDefaultState.preference) {
712
721
  var mobileFallbackNote = document.createElement("div");
@@ -727,7 +736,7 @@ function renderMobileSessionsInto(container) {
727
736
  if (!isInstalled) vBtn.classList.add("disabled");
728
737
  if (vendor === mobileDefaultVendor) vBtn.classList.add("active");
729
738
  vBtn.innerHTML = '<img src="' + VENDOR_AVATARS[vendor] + '" class="mobile-session-new-icon" alt="">' +
730
- '<span>' + name + '</span>' +
739
+ '<span>' + (isInstalled ? "Create with " : "Learn about ") + name + '</span>' +
731
740
  (isExperimentalVendor(vendor) ? '<span class="vendor-experimental-badge" aria-label="Experimental integration" title="Experimental integration; not yet validated through direct use testing">🧪</span>' : '') +
732
741
  (isInstalled ? "" : '<span class="mobile-vendor-note">Not installed</span>');
733
742
  vBtn.addEventListener("click", function () {
@@ -735,6 +744,9 @@ function renderMobileSessionsInto(container) {
735
744
  window.open(VENDOR_HOMEPAGES[vendor], "_blank", "noopener");
736
745
  return;
737
746
  }
747
+ vendorList.classList.add("hidden");
748
+ controls.providerButton.classList.remove("expanded");
749
+ controls.providerButton.setAttribute("aria-expanded", "false");
738
750
  startNewSession(vendor);
739
751
  closeMobileSheet();
740
752
  });
@@ -17,6 +17,7 @@ import { openGroup, separateGroup } from './split-view.js';
17
17
  import { groupedSessionIds } from './split-group-helpers.js';
18
18
  import { defaultVendorUnavailable, resolvePreferredVendor, saveDefaultVendor } from './default-vendor.js';
19
19
  import { openPairDialog } from './split-pair-ui.js';
20
+ import { renderSessionCreationCta } from './session-creation-cta.js';
20
21
  import {
21
22
  hierarchyItemMatches,
22
23
  prepareSidebarHierarchy,
@@ -53,10 +54,11 @@ var armedDeleteTimer = null;
53
54
  store.subscribe(function (state, previous) {
54
55
  if (state.defaultVendorState !== previous.defaultVendorState || state.installedVendors !== previous.installedVendors) {
55
56
  var reopenNewMenu = !!(sessionCtxMenu && sessionCtxMenuAnchor);
57
+ var sessionList = getSessionListEl();
56
58
  if (reopenNewMenu) closeSessionCtxMenu();
57
- if (getSessionListEl()) renderSessionList();
58
- if (reopenNewMenu) {
59
- var freshAnchor = getSessionListEl().querySelector(".session-top-action.split-chevron");
59
+ if (sessionList) renderSessionList();
60
+ if (reopenNewMenu && sessionList) {
61
+ var freshAnchor = sessionList.querySelector(".session-top-action.split-provider");
60
62
  if (freshAnchor) showNewSessionMenu(freshAnchor, resolveDefaultVendor());
61
63
  }
62
64
  }
@@ -356,58 +358,50 @@ export function startNewSession(vendor, extra) {
356
358
  });
357
359
  }
358
360
 
359
- function renderSessionTopActions() {
361
+ export function renderSessionTopActions() {
360
362
  var wrap = document.createElement("div");
361
363
  wrap.className = "session-top-actions";
362
364
 
363
- // Single split button. The main half creates a session with the project's
364
- // last-used vendor (for Claude the server applies the claudeOpenMode pref);
365
- // the chevron opens the vendor picker plus alternate launch modes.
366
365
  var defaultVendor = resolveDefaultVendor();
367
-
368
- var cell = document.createElement("div");
369
- cell.className = "session-top-action-split";
370
-
371
- var mainBtn = document.createElement("button");
372
- mainBtn.className = "session-top-action split-main";
373
- mainBtn.type = "button";
374
- mainBtn.title = "New " + (VENDOR_NAMES[defaultVendor] || defaultVendor) + " session";
375
- if (defaultVendorUnavailable(store.get('installedVendors') || [])) mainBtn.title += " (saved default is unavailable; using installed priority)";
376
- mainBtn.innerHTML = '<img src="' + (VENDOR_AVATARS[defaultVendor] || VENDOR_AVATARS.claude) +
377
- '" class="session-top-action-icon" alt=""><span>New session</span>';
378
- mainBtn.addEventListener("click", function () {
379
- startNewSession(defaultVendor);
380
- });
381
- cell.appendChild(mainBtn);
382
-
383
- var chevron = document.createElement("button");
384
- chevron.className = "session-top-action split-chevron";
385
- chevron.type = "button";
386
- chevron.title = "Choose a vendor or launch mode";
387
- chevron.setAttribute("aria-label", "Choose a vendor or launch mode");
388
- chevron.innerHTML = iconHtml("chevron-down");
389
- chevron.addEventListener("click", function (e) {
390
- e.preventDefault();
391
- e.stopPropagation();
392
- if (sessionCtxMenu) { closeSessionCtxMenu(); return; }
393
- showNewSessionMenu(chevron, defaultVendor);
366
+ var vendorName = VENDOR_NAMES[defaultVendor] || defaultVendor;
367
+ var createTitle = "Create a " + vendorName + " session";
368
+ if (defaultVendorUnavailable(store.get('installedVendors') || [])) createTitle += " (saved default is unavailable; using installed priority)";
369
+ var controls;
370
+ controls = renderSessionCreationCta({
371
+ rowClass: "session-top-action-split",
372
+ createClass: "session-top-action split-main",
373
+ providerClass: "session-top-action split-provider",
374
+ iconClass: "session-top-action-icon",
375
+ vendorKey: defaultVendor,
376
+ vendorName: vendorName,
377
+ vendorAvatar: VENDOR_AVATARS[defaultVendor] || VENDOR_AVATARS.claude,
378
+ createTitle: createTitle,
379
+ providerTitle: "Switch AI provider (current: " + vendorName + ")",
380
+ menuId: "session-new-menu",
381
+ onCreate: function () { startNewSession(defaultVendor); },
382
+ onProvider: function (e) {
383
+ e.preventDefault();
384
+ e.stopPropagation();
385
+ if (sessionCtxMenu) { closeSessionCtxMenu(); return; }
386
+ showNewSessionMenu(controls.providerButton, defaultVendor);
387
+ },
394
388
  });
395
- cell.appendChild(chevron);
396
-
397
- wrap.appendChild(cell);
389
+ wrap.appendChild(controls.element);
398
390
  return wrap;
399
391
  }
400
392
 
401
- // Dropdown anchored to the split-button chevron. Reuses the session-ctx-menu
402
- // element/var so the global document click handler closes it.
393
+ // Provider disclosure anchored to the grouped provider control. Reuses the
394
+ // session-ctx-menu element/var so the global document click handler closes it.
403
395
  function showNewSessionMenu(anchorBtn, defaultVendor) {
404
396
  closeSessionCtxMenu();
405
397
 
406
398
  var menu = document.createElement("div");
407
399
  menu.className = "session-ctx-menu session-new-menu";
400
+ menu.id = "session-new-menu";
408
401
  var installed = store.get('installedVendors') || [];
409
402
  var defaultVendorState = store.get('defaultVendorState') || {};
410
403
  sessionCtxMenuAnchor = anchorBtn;
404
+ anchorBtn.setAttribute("aria-expanded", "true");
411
405
 
412
406
  if (defaultVendorUnavailable(installed) && defaultVendorState.preference) {
413
407
  var fallbackNote = document.createElement("div");
@@ -430,7 +424,7 @@ function showNewSessionMenu(anchorBtn, defaultVendor) {
430
424
  if (vendor === defaultVendor) item.classList.add("active");
431
425
  item.type = "button";
432
426
  item.innerHTML = '<img src="' + VENDOR_AVATARS[vendor] + '" class="session-top-action-icon" alt="">' +
433
- '<span class="session-new-vendor-name">' + name + '</span>' +
427
+ '<span class="session-new-vendor-name">' + (isInstalled ? "Create with " : "Learn about ") + name + '</span>' +
434
428
  (isExperimentalVendor(vendor) ? '<span class="vendor-experimental-badge" aria-label="Experimental integration" title="Experimental integration; not yet validated through direct use testing">🧪</span>' : '') +
435
429
  (isInstalled ? "" : '<span class="session-new-vendor-note">Not installed</span>' + iconHtml("external-link"));
436
430
  item.title = isInstalled
@@ -526,6 +520,12 @@ function showNewSessionMenu(anchorBtn, defaultVendor) {
526
520
 
527
521
  document.body.appendChild(menu);
528
522
  sessionCtxMenu = menu;
523
+ menu.addEventListener("keydown", function (e) {
524
+ if (e.key !== "Escape") return;
525
+ e.preventDefault();
526
+ e.stopPropagation();
527
+ closeSessionCtxMenu(true);
528
+ });
529
529
  refreshIcons();
530
530
 
531
531
  requestAnimationFrame(function () {
@@ -542,6 +542,8 @@ function showNewSessionMenu(anchorBtn, defaultVendor) {
542
542
  if (menuRect.bottom > window.innerHeight - 8) {
543
543
  menu.style.top = (btnRect.top - menuRect.height - 2) + "px";
544
544
  }
545
+ var firstMenuItem = menu.querySelector("button:not(:disabled)");
546
+ if (firstMenuItem) firstMenuItem.focus();
545
547
  });
546
548
  }
547
549
 
@@ -718,13 +720,16 @@ export function getExpandedLoopRuns() {
718
720
 
719
721
  // --- Context menu ---
720
722
 
721
- function closeSessionCtxMenu() {
723
+ function closeSessionCtxMenu(restoreFocus) {
724
+ var anchor = sessionCtxMenuAnchor;
725
+ if (anchor) anchor.setAttribute("aria-expanded", "false");
722
726
  sessionCtxMenuAnchor = null;
723
727
  if (sessionCtxMenu) {
724
728
  sessionCtxMenu.remove();
725
729
  sessionCtxMenu = null;
726
730
  sessionCtxSessionId = null;
727
731
  }
732
+ if (restoreFocus && anchor && document.contains(anchor)) anchor.focus();
728
733
  }
729
734
 
730
735
  function showSessionCtxMenu(anchorBtn, sessionId, title, cliSid, sessionData) {
@@ -1429,14 +1434,20 @@ function renderSplitGroupItem(group, members) {
1429
1434
  escapeHtml(VENDOR_NAMES[vendor] || vendor) + '">';
1430
1435
  }
1431
1436
  html += "</span>";
1432
- if (members[0].isProcessing || members[1].isProcessing) html += '<span class="session-processing"></span>';
1433
- var splitBackgroundTaskCount = (members[0].backgroundTaskCount || 0) + (members[1].backgroundTaskCount || 0);
1437
+ var splitProcessing = false;
1438
+ var splitBackgroundTaskCount = 0;
1439
+ for (var si = 0; si < members.length; si++) {
1440
+ if (members[si].isProcessing) splitProcessing = true;
1441
+ splitBackgroundTaskCount += members[si].backgroundTaskCount || 0;
1442
+ }
1443
+ if (splitProcessing) html += '<span class="session-processing"></span>';
1434
1444
  if (splitBackgroundTaskCount) html += '<span class="session-background-task-count">' + splitBackgroundTaskCount + '</span>';
1435
1445
  html += '<span class="split-group-name">' + highlightMatch(group.name || "Split", searchQuery) + "</span>";
1436
1446
  text.innerHTML = html;
1437
1447
  el.appendChild(text);
1438
1448
 
1439
- var unread = (members[0].unread || 0) + (members[1].unread || 0);
1449
+ var unread = 0;
1450
+ for (var ui = 0; ui < members.length; ui++) unread += members[ui].unread || 0;
1440
1451
  var badge = document.createElement("span");
1441
1452
  badge.className = "session-unread-badge" + (unread > 0 ? " has-unread" : "");
1442
1453
  badge.dataset.splitGroupId = group.id;
@@ -1532,15 +1543,24 @@ export function renderSessionList(sessions) {
1532
1543
  }
1533
1544
  for (var sg = 0; sg < splitGroups.length; sg++) {
1534
1545
  var splitGroup = splitGroups[sg];
1535
- var leftMember = sessionsById.get(splitGroup.members[0]);
1536
- var rightMember = sessionsById.get(splitGroup.members[1]);
1537
- if (!leftMember || !rightMember) continue;
1538
- if (hierarchySessionIds.has(leftMember.id) || hierarchySessionIds.has(rightMember.id)) continue;
1546
+ var splitMembers = [];
1547
+ for (var smi = 0; smi < splitGroup.members.length; smi++) {
1548
+ var splitMember = sessionsById.get(splitGroup.members[smi]);
1549
+ if (splitMember) splitMembers.push(splitMember);
1550
+ }
1551
+ if (splitMembers.length !== splitGroup.members.length) continue;
1552
+ var hasHierarchyMember = false;
1553
+ for (var hmi = 0; hmi < splitMembers.length; hmi++) {
1554
+ if (hierarchySessionIds.has(splitMembers[hmi].id)) hasHierarchyMember = true;
1555
+ }
1556
+ if (hasHierarchyMember) continue;
1557
+ var splitLastActivity = 0;
1558
+ for (var lai = 0; lai < splitMembers.length; lai++) splitLastActivity = Math.max(splitLastActivity, splitMembers[lai].lastActivity || 0);
1539
1559
  items.push({
1540
1560
  type: "split-group",
1541
1561
  group: splitGroup,
1542
- members: [leftMember, rightMember],
1543
- lastActivity: Math.max(leftMember.lastActivity || 0, rightMember.lastActivity || 0),
1562
+ members: splitMembers,
1563
+ lastActivity: splitLastActivity,
1544
1564
  });
1545
1565
  }
1546
1566
  var groupKeys = Object.keys(loopGroups);
@@ -1567,8 +1587,13 @@ export function renderSessionList(sessions) {
1567
1587
  continue;
1568
1588
  }
1569
1589
  if ((item.type === "driver-hierarchy" || item.type === "orphan-workers") && !hierarchyItemMatches(item, searchMatchIds)) continue;
1570
- if (item.type === "split-group" && searchMatchIds !== null &&
1571
- !searchMatchIds.has(item.members[0].id) && !searchMatchIds.has(item.members[1].id)) continue;
1590
+ if (item.type === "split-group" && searchMatchIds !== null) {
1591
+ var splitSearchMatch = false;
1592
+ for (var splitSearchIndex = 0; splitSearchIndex < item.members.length; splitSearchIndex++) {
1593
+ if (searchMatchIds.has(item.members[splitSearchIndex].id)) splitSearchMatch = true;
1594
+ }
1595
+ if (!splitSearchMatch) continue;
1596
+ }
1572
1597
  if ((item.type === "session" || item.type === "driver-hierarchy") && item.data && item.data.bookmarked) {
1573
1598
  bookmarkedItems.push(item);
1574
1599
  } else {
@@ -12,15 +12,87 @@ export function findSplitGroup(groups, memberIds) {
12
12
  var list = groups || [];
13
13
  for (var i = 0; i < list.length; i++) {
14
14
  var members = list[i].members || [];
15
- if (members.length === 2 && members[0] === memberIds[0] && members[1] === memberIds[1]) return list[i];
15
+ if (members.length !== memberIds.length) continue;
16
+ var matches = true;
17
+ for (var j = 0; j < memberIds.length; j++) {
18
+ if (members.indexOf(memberIds[j]) === -1) { matches = false; break; }
19
+ }
20
+ if (matches) return list[i];
16
21
  }
17
22
  return null;
18
23
  }
19
24
 
25
+ // Resolve the visible order from the explicit role contract. Member array
26
+ // order is persistence detail and must never decide who is the Driver.
27
+ export function splitGroupRoles(group) {
28
+ if (!group || !Array.isArray(group.members)) return null;
29
+ var pair = group.pair;
30
+ if (!pair || typeof pair !== "object" || Array.isArray(pair)) return null;
31
+ var hasVersion = Object.prototype.hasOwnProperty.call(pair, "version");
32
+ var hasLegacyWorker = Object.prototype.hasOwnProperty.call(pair, "workerId");
33
+ var hasWorkerIds = Object.prototype.hasOwnProperty.call(pair, "workerIds");
34
+ if (hasVersion && pair.version !== 2) return null;
35
+ if (hasVersion && hasLegacyWorker) return null;
36
+ if (hasVersion && !hasWorkerIds) return null;
37
+ if (!hasVersion && hasWorkerIds) return null;
38
+ if (!hasVersion && !hasLegacyWorker) return null;
39
+ var driverId = pair.driverId;
40
+ var workerIds = hasWorkerIds ? (Array.isArray(pair.workerIds) ? pair.workerIds.slice() : []) :
41
+ (pair.workerId !== undefined ? [pair.workerId] : []);
42
+ if (!Number.isInteger(driverId) || workerIds.length < 1 || workerIds.length > 2) return null;
43
+ if (group.members.indexOf(driverId) === -1) return null;
44
+ var ordered = [driverId];
45
+ for (var i = 0; i < workerIds.length; i++) {
46
+ if (!Number.isInteger(workerIds[i]) || workerIds[i] === driverId || group.members.indexOf(workerIds[i]) === -1 || ordered.indexOf(workerIds[i]) !== -1) return null;
47
+ ordered.push(workerIds[i]);
48
+ }
49
+ if (group.members.length !== ordered.length) return null;
50
+ for (var mi = 0; mi < group.members.length; mi++) if (ordered.indexOf(group.members[mi]) === -1) return null;
51
+ return { driverId: driverId, workerIds: workerIds, orderedIds: ordered, version: pair.version || null };
52
+ }
53
+
54
+ export function splitGroupMemberIds(group) {
55
+ var roles = splitGroupRoles(group);
56
+ return roles ? roles.orderedIds : (group && Array.isArray(group.members) ? group.members.slice() : []);
57
+ }
58
+
59
+ export function splitGroupActiveAnchor(group, activeId) {
60
+ var roles = splitGroupRoles(group);
61
+ if (roles) return roles.driverId;
62
+ var members = group && Array.isArray(group.members) ? group.members : [];
63
+ return members.indexOf(activeId) !== -1 ? activeId : (members[0] || null);
64
+ }
65
+
20
66
  export function isConfiguredWorker(groups, sessionId) {
21
67
  var list = groups || [];
22
68
  for (var i = 0; i < list.length; i++) {
23
- if (list[i].pair && list[i].pair.workerId === sessionId) return true;
69
+ var roles = splitGroupRoles(list[i]);
70
+ if (roles && roles.workerIds.indexOf(sessionId) !== -1) return true;
24
71
  }
25
72
  return false;
26
73
  }
74
+
75
+ export function splitWorkerCloseRequest(group, sessions, projectSlug, workerId, requestId) {
76
+ var roles = splitGroupRoles(group);
77
+ if (!roles || roles.version !== 2 || roles.workerIds.indexOf(workerId) === -1) return null;
78
+ var anchors = group && group.pairAnchors;
79
+ if (!anchors || anchors.version !== 2 || !anchors.driver || !Array.isArray(anchors.workers)) return null;
80
+ var workerIndex = roles.workerIds.indexOf(workerId);
81
+ var workerAnchor = anchors.workers[workerIndex];
82
+ var worker = null;
83
+ var list = sessions || [];
84
+ for (var i = 0; i < list.length; i++) if (list[i].id === workerId) worker = list[i];
85
+ if (!workerAnchor || !anchors.driver.origin || !workerAnchor.origin || !worker || !Number.isInteger(worker.workerGeneration)) return null;
86
+ return {
87
+ type: "split_worker_close",
88
+ requestId: requestId,
89
+ projectSlug: projectSlug,
90
+ groupId: group.id,
91
+ driverId: roles.driverId,
92
+ driverOriginId: anchors.driver.origin,
93
+ workerId: workerId,
94
+ workerOriginId: workerAnchor.origin,
95
+ generation: worker.workerGeneration,
96
+ expectedWorkerIds: roles.workerIds.slice(),
97
+ };
98
+ }
@@ -5,6 +5,7 @@ import { buildAgentVendorSelect, fillAgentModels, buildAgentEffortSelect, fillAg
5
5
  import { showToast } from './utils.js';
6
6
  import { getCachedSessions } from './sidebar-sessions.js';
7
7
  import { appendWorkerRuntime } from './split-worker-runtime.js';
8
+ import { splitGroupRoles } from './split-group-helpers.js';
8
9
 
9
10
  var pairDialog = null;
10
11
  // Set when the dialog was opened via "Add Split Worker" on an existing session:
@@ -245,6 +246,8 @@ export function syncPairChrome(host, split) {
245
246
  var paneEls = host.querySelectorAll(".split-pane");
246
247
  for (var paneIndex = 0; paneIndex < paneEls.length; paneIndex++) {
247
248
  paneEls[paneIndex].classList.remove("split-pane-role-driver", "split-pane-role-worker");
249
+ paneEls[paneIndex].style.removeProperty("--split-pane-order");
250
+ paneEls[paneIndex].style.removeProperty("--split-worker-row");
248
251
  }
249
252
  var runtimeHeaders = host.querySelectorAll(".has-worker-runtime");
250
253
  for (var ri = 0; ri < runtimeHeaders.length; ri++) runtimeHeaders[ri].classList.remove("has-worker-runtime");
@@ -268,10 +271,23 @@ export function syncPairChrome(host, split) {
268
271
  }
269
272
  return;
270
273
  }
274
+ var roles = splitGroupRoles(group);
275
+ if (!roles) return;
276
+ var workerIndex = 0;
271
277
  for (var pi = 0; pi < split.panes.length && pi < headers.length; pi++) {
272
- (function (sessionId, header) {
273
- var isDriver = sessionId === group.pair.driverId;
274
- if (paneEls[pi]) paneEls[pi].classList.add(isDriver ? "split-pane-role-driver" : "split-pane-role-worker");
278
+ var desiredPane = split.panes[pi];
279
+ var desiredPaneEl = null;
280
+ for (var pei = 0; pei < paneEls.length; pei++) {
281
+ if (paneEls[pei].dataset.sessionId === String(desiredPane.sessionId)) desiredPaneEl = paneEls[pei];
282
+ }
283
+ if (!desiredPaneEl) continue;
284
+ (function (sessionId, header, paneEl) {
285
+ var isDriver = sessionId === roles.driverId;
286
+ if (paneEl) {
287
+ paneEl.classList.add(isDriver ? "split-pane-role-driver" : "split-pane-role-worker");
288
+ paneEl.style.setProperty("--split-pane-order", isDriver ? "2" : String(workerIndex));
289
+ if (!isDriver) paneEl.style.setProperty("--split-worker-row", String(workerIndex + 1));
290
+ }
275
291
  var role = isDriver ? "Driver" : "Split Worker";
276
292
  var badge = document.createElement("span");
277
293
  badge.className = "split-pair-role split-pair-role-" + role.toLowerCase().replace(/ /g, "-");
@@ -287,18 +303,24 @@ export function syncPairChrome(host, split) {
287
303
  if (sessions[wi].id === sessionId) { workerSession = sessions[wi]; break; }
288
304
  }
289
305
  appendWorkerRuntime(header, workerSession);
306
+ workerIndex++;
290
307
  }
291
- })(split.panes[pi].sessionId, headers[pi]);
308
+ })(desiredPane.sessionId, desiredPaneEl.querySelector(".split-pane-header"), desiredPaneEl);
292
309
  }
293
310
  var active = (store.get('splitDelegations') || {})[group.id];
294
311
  if (!active || !active.active) return;
295
312
  host.classList.add("split-delegating");
296
313
  var fromIndex = split.panes[0].sessionId === active.from ? 0 : 1;
297
314
  host.classList.add(fromIndex === 0 ? "split-direction-right" : "split-direction-left");
298
- for (var si = 0; si < split.panes.length && si < headers.length; si++) {
315
+ for (var si = 0; si < split.panes.length; si++) {
316
+ var livePane = null;
317
+ for (var lpi = 0; lpi < paneEls.length; lpi++) {
318
+ if (paneEls[lpi].dataset.sessionId === String(split.panes[si].sessionId)) livePane = paneEls[lpi];
319
+ }
320
+ if (!livePane) continue;
299
321
  var live = document.createElement("span");
300
322
  live.className = "split-pair-live";
301
323
  live.textContent = split.panes[si].sessionId === active.to ? "Following direction" : "Waiting";
302
- headers[si].appendChild(live);
324
+ livePane.querySelector(".split-pane-header").appendChild(live);
303
325
  }
304
326
  }
@@ -0,0 +1,28 @@
1
+ // Keyed DOM reconciliation for the parent split shell. Existing pane nodes
2
+ // (and their iframe browsing contexts) are reused by stable project/session
3
+ // identity while metadata and visual order are refreshed.
4
+
5
+ export function splitPaneIdentity(pane) {
6
+ return String(pane && pane.slug || "") + "#" + String(pane && pane.sessionId || "");
7
+ }
8
+
9
+ export function reconcileSplitPanes(host, panes, createPane, updatePane) {
10
+ var desired = Array.isArray(panes) ? panes : [];
11
+ var existing = host.querySelectorAll(".split-pane");
12
+ var byKey = new Map();
13
+ for (var i = 0; i < existing.length; i++) byKey.set(existing[i].dataset.splitPaneKey, existing[i]);
14
+ var used = new Set();
15
+ for (var pi = 0; pi < desired.length; pi++) {
16
+ var pane = desired[pi];
17
+ var key = splitPaneIdentity(pane);
18
+ var paneEl = byKey.get(key);
19
+ if (!paneEl) paneEl = createPane(pane, pi);
20
+ paneEl.dataset.splitPaneKey = key;
21
+ updatePane(paneEl, pane, pi);
22
+ used.add(paneEl);
23
+ if (!paneEl.parentNode) host.appendChild(paneEl);
24
+ }
25
+ for (var ei = existing.length - 1; ei >= 0; ei--) {
26
+ if (!used.has(existing[ei])) existing[ei].remove();
27
+ }
28
+ }
@@ -0,0 +1,107 @@
1
+ import { store } from './store.js';
2
+ import { getWs } from './ws-ref.js';
3
+ import { getCachedSessions } from './sidebar-sessions.js';
4
+ import { iconHtml } from './icons.js';
5
+ import { formatTokens } from './app-panels.js';
6
+ import { VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
7
+ import { createPermissionControl, bindPermissionControl, renderPermissionControl } from './permission-control.js';
8
+ import { splitGroupRoles } from './split-group-helpers.js';
9
+
10
+ function sessionById(sessionId) {
11
+ var sessions = getCachedSessions() || [];
12
+ for (var i = 0; i < sessions.length; i++) if (sessions[i].id === sessionId) return sessions[i];
13
+ return null;
14
+ }
15
+
16
+ function paneUrl(pane) {
17
+ return "/p/" + encodeURIComponent(pane.slug) + "/?pane=1&session=" + encodeURIComponent(pane.sessionId);
18
+ }
19
+
20
+ function startPaneRename(header, titleEl, pane) {
21
+ if (header.querySelector(".split-pane-rename-input")) return;
22
+ var input = document.createElement("input");
23
+ input.type = "text"; input.className = "split-pane-rename-input"; input.value = pane.title;
24
+ titleEl.style.display = "none"; header.insertBefore(input, titleEl); input.focus(); input.select();
25
+ var done = false;
26
+ function finish(commit) {
27
+ if (done) return;
28
+ done = true;
29
+ var newTitle = input.value.trim(); input.remove(); titleEl.style.display = "";
30
+ if (!commit || !newTitle || newTitle === pane.title) return;
31
+ pane.title = newTitle; titleEl.textContent = newTitle;
32
+ var ws = getWs();
33
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: "rename_session", id: pane.sessionId, title: newTitle }));
34
+ }
35
+ input.addEventListener("keydown", function (event) {
36
+ if (event.key === "Enter") { event.preventDefault(); finish(true); }
37
+ if (event.key === "Escape") { event.preventDefault(); finish(false); }
38
+ });
39
+ input.addEventListener("blur", function () { finish(true); });
40
+ input.addEventListener("click", function (event) { event.stopPropagation(); });
41
+ }
42
+
43
+ function permissionState(session) {
44
+ var mode = session && (session.runtimeMode || session.mode || "gui");
45
+ var worker = !!session && isConfiguredWorker(store.get('splitGroups'), session.id);
46
+ return {
47
+ projectSlug: store.get('currentSlug'), sessionId: session && session.id,
48
+ vendor: session && session.vendor || store.get('currentVendor') || "claude",
49
+ permissionMode: session && session.permissionMode || "default",
50
+ effectivePermissionMode: session && session.effectivePermissionMode || null,
51
+ permissionCapabilities: session && session.permissionCapabilities || { auto: false, mcpOverride: false },
52
+ connected: store.get('connected'), globalPermissionModeForced: store.get('skipPermsEnabled') === true,
53
+ visible: !!session && !worker && mode === "gui", locked: worker,
54
+ };
55
+ }
56
+
57
+ function isConfiguredWorker(groups, sessionId) {
58
+ for (var i = 0; i < (groups || []).length; i++) {
59
+ var roles = splitGroupRoles(groups[i]);
60
+ if (roles && roles.workerIds.indexOf(sessionId) !== -1) return true;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ export function updatePaneFullAccessButton(button, session) {
66
+ if (button) renderPermissionControl(button, permissionState(session));
67
+ }
68
+
69
+ export function createSplitPane(pane, closePane) {
70
+ var paneEl = document.createElement("section"); paneEl.className = "split-pane";
71
+ var header = document.createElement("header"); header.className = "split-pane-header"; header.title = pane.title;
72
+ var session = sessionById(pane.sessionId); var vendor = session && session.vendor || "claude";
73
+ var vendorIcon = document.createElement("img"); vendorIcon.className = "split-pane-vendor";
74
+ vendorIcon.src = VENDOR_AVATARS[vendor] || VENDOR_AVATARS.claude; vendorIcon.alt = ""; vendorIcon.title = VENDOR_NAMES[vendor] || vendor; header.appendChild(vendorIcon);
75
+ var title = document.createElement("span"); title.className = "split-pane-title"; title.textContent = pane.title; title.title = "Rename session";
76
+ title.addEventListener("click", function () { startPaneRename(header, title, pane); }); header.appendChild(title);
77
+ var frame = document.createElement("iframe"); frame.className = "split-pane-frame"; frame.dataset.projectSlug = pane.slug;
78
+ frame.dataset.sessionId = String(pane.sessionId); frame.src = paneUrl(pane); frame.title = pane.title;
79
+ var ctxChip = document.createElement("button"); ctxChip.type = "button"; ctxChip.className = "split-pane-context"; ctxChip.title = "Context usage";
80
+ ctxChip.innerHTML = '<span class="split-pane-context-bar"><span class="split-pane-context-fill"></span></span><span class="split-pane-context-label"></span>';
81
+ ctxChip.addEventListener("click", function () { if (frame.contentWindow) frame.contentWindow.postMessage({ type: "clay-pane-toggle-context" }, window.location.origin); }); header.appendChild(ctxChip);
82
+ var fullAccess = createPermissionControl("split-pane-full-access hidden", "Driver session permission mode");
83
+ updatePaneFullAccessButton(fullAccess, session); bindPermissionControl(fullAccess, function () { return permissionState(sessionById(pane.sessionId)); }); header.appendChild(fullAccess); header.insertBefore(fullAccess, ctxChip);
84
+ var close = document.createElement("button"); close.type = "button"; close.className = "split-pane-close"; close.title = "Close pane"; close.setAttribute("aria-label", "Close pane"); close.innerHTML = iconHtml("x");
85
+ close.dataset.sessionId = String(pane.sessionId); close.addEventListener("click", function () { closePane(Number(this.dataset.sessionId)); }); header.appendChild(close);
86
+ paneEl.appendChild(header); paneEl.appendChild(frame); return paneEl;
87
+ }
88
+
89
+ export function updateSplitPaneMetadata(paneEl, pane) {
90
+ var session = sessionById(pane.sessionId); var header = paneEl.querySelector(".split-pane-header");
91
+ var title = paneEl.querySelector(".split-pane-title"); var frame = paneEl.querySelector(".split-pane-frame"); var vendor = paneEl.querySelector(".split-pane-vendor");
92
+ paneEl.dataset.sessionId = String(pane.sessionId); paneEl.dataset.projectSlug = pane.slug;
93
+ if (header) header.title = pane.title; if (title && title.style.display !== "none") title.textContent = pane.title; if (frame) frame.title = pane.title;
94
+ if (vendor) { var vendorName = session && session.vendor || "claude"; vendor.src = VENDOR_AVATARS[vendorName] || VENDOR_AVATARS.claude; vendor.title = VENDOR_NAMES[vendorName] || vendorName; }
95
+ var close = paneEl.querySelector(".split-pane-close");
96
+ if (close) {
97
+ close.dataset.sessionId = String(pane.sessionId); close.disabled = false; close.title = "Close pane"; close.setAttribute("aria-label", close.title);
98
+ }
99
+ }
100
+
101
+ export function updatePaneContextChip(paneEl, msg) {
102
+ if (!paneEl) return; var chip = paneEl.querySelector(".split-pane-context"); if (!chip) return; var pct = msg.pct || 0; if (pct <= 0) return;
103
+ chip.classList.add("has-data"); var fill = chip.querySelector(".split-pane-context-fill"); var label = chip.querySelector(".split-pane-context-label");
104
+ fill.style.width = Math.min(100, pct).toFixed(1) + "%"; fill.className = "split-pane-context-fill" + (msg.cls || ""); label.textContent = pct.toFixed(0) + "%";
105
+ var tip = "Context " + pct.toFixed(0) + "% (" + formatTokens(msg.used || 0) + " / " + formatTokens(msg.win || 0) + " tokens)";
106
+ if (msg.cost) tip += " · $" + msg.cost.toFixed(4); if (msg.model && msg.model !== "-") tip += " · " + msg.model; chip.title = tip;
107
+ }