clay-server 2.47.0-beta.6 → 3.0.0-beta.2

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 (46) hide show
  1. package/lib/daemon.js +6 -27
  2. package/lib/project-connection.js +21 -8
  3. package/lib/project-session-pair.js +312 -0
  4. package/lib/project-session-spawn.js +8 -4
  5. package/lib/project-sessions.js +108 -10
  6. package/lib/project-user-message.js +18 -8
  7. package/lib/project.js +33 -0
  8. package/lib/public/app.js +20 -0
  9. package/lib/public/css/menus.css +83 -1
  10. package/lib/public/css/messages.css +14 -0
  11. package/lib/public/css/pane.css +420 -0
  12. package/lib/public/css/sidebar.css +40 -0
  13. package/lib/public/css/sticky-notes.css +2 -0
  14. package/lib/public/index.html +4 -0
  15. package/lib/public/modules/app-connection.js +6 -0
  16. package/lib/public/modules/app-favicon.js +5 -0
  17. package/lib/public/modules/app-header.js +101 -6
  18. package/lib/public/modules/app-messages.js +59 -6
  19. package/lib/public/modules/app-panels.js +49 -27
  20. package/lib/public/modules/app-rendering.js +11 -4
  21. package/lib/public/modules/pane-bridge.js +41 -0
  22. package/lib/public/modules/pane-session.js +18 -0
  23. package/lib/public/modules/sidebar-sessions.js +175 -1
  24. package/lib/public/modules/sidebar.js +2 -0
  25. package/lib/public/modules/split-group-helpers.js +18 -0
  26. package/lib/public/modules/split-pair-ui.js +324 -0
  27. package/lib/public/modules/split-view.js +499 -0
  28. package/lib/public/modules/sticky-notes.js +32 -91
  29. package/lib/public/style.css +1 -0
  30. package/lib/sdk-bridge.js +111 -20
  31. package/lib/sdk-message-processor.js +2 -2
  32. package/lib/server.js +24 -4
  33. package/lib/session-hygiene.js +100 -0
  34. package/lib/session-pair-mcp-server.js +28 -0
  35. package/lib/session-split-groups.js +298 -0
  36. package/lib/sessions.js +163 -34
  37. package/lib/ws-request.js +14 -0
  38. package/lib/ws-schema.js +13 -0
  39. package/lib/yoke/adapters/claude.js +32 -0
  40. package/lib/yoke/adapters/codex.js +100 -77
  41. package/lib/yoke/adapters/kiro.js +34 -35
  42. package/lib/yoke/codex-app-server.js +56 -6
  43. package/lib/yoke/index.js +1 -0
  44. package/lib/yoke/skill-discovery.js +196 -0
  45. package/lib/yoke/vendor-registry.js +34 -0
  46. package/package.json +2 -2
@@ -19,6 +19,8 @@ import { renderKnowledgeList, handleKnowledgeContent } from './mate-knowledge.js
19
19
  import { renderMemoryList } from './mate-memory.js';
20
20
  import { handlePaletteSessionSwitch, setPaletteVersion } from './command-palette.js';
21
21
  import { handleFindInSessionResults } from './session-search.js';
22
+ import { syncPaneTitles, maybeRestoreSplitGroup, openGroup } from './split-view.js';
23
+ import { showPairDialog, handlePairCreated, handleSplitDelegation } from './split-pair-ui.js';
22
24
  import { handleInputSync, autoResize, builtinCommands, setScheduleBtnDisabled } from './input.js';
23
25
  import { startThinking, appendThinking, stopThinking, resetThinkingGroup, createToolItem, updateToolExecuting, updateToolResult, markAllToolsDone, closeToolGroup, removeToolFromGroup, resetToolState, getTools, getPlanContent, setPlanContent, renderPlanBanner, renderPlanCard, getTodoTools, handleTodoWrite, handleTaskCreate, handleTaskUpdate, applyDeadSessionTodoCompaction, isPlanFilePath, enableMainInput, addTurnMeta, updateSubagentActivity, addSubagentToolEntry, markSubagentDone, initSubagentStop, updateSubagentProgress, updateSubagentTaskStatus, renderAskUserQuestion, markAskUserAnswered, renderPermissionRequest, markPermissionCancelled, markPermissionResolved, renderElicitationRequest, markElicitationResolved, renderUserDialogRequest, markUserDialogResolved, updateThinkingTokens } from './tools.js';
24
26
  import { showDoneNotification, playDoneSound, isNotifAlertEnabled, isNotifSoundEnabled } from './notifications.js';
@@ -46,6 +48,7 @@ import { setActivity, startUrgentBlink, stopUrgentBlink, blinkSessionDot, update
46
48
  import { setStatus } from './app-connection.js';
47
49
  import { handleWhatsNewState, handleWhatsNewSeenResult, setKnownEntries as setWhatsNewKnownEntries } from './whats-new.js';
48
50
  import { closeArticle as closeWhatsNewArticle } from './whats-new-article.js';
51
+ import { resolvePaneSession, resolveSwitchedVendor } from './pane-session.js';
49
52
  import { getModelEffortLevels, accumulateUsage, updateUsagePanel, accumulateContext, updateContextPanel, renderCtxPopover, updateStatusPanel } from './app-panels.js';
50
53
  import { updateProjectList, updateTitleBarProjectIdentity, resetClientState, showUpdateAvailable, handleRemoveProjectCheckResult, handleRemoveProjectResult, handleBrowseDirResult, handleAddProjectResult, handleCloneProgress } from './app-projects.js';
51
54
  import { updateHistorySentinel, prependOlderHistory } from './app-header.js';
@@ -549,9 +552,42 @@ export function processMessage(msg) {
549
552
  case "session_list":
550
553
  renderMateSessionList(msg.sessions || []);
551
554
  renderSessionList(msg.sessions || []);
555
+ syncPaneTitles();
556
+ var panePin = resolvePaneSession(store.get('paneMode'), store.get('panePinPending'), store.get('paneSessionId'), msg.sessions);
557
+ if (panePin.consumed) {
558
+ store.set({ panePinPending: false });
559
+ if (panePin.sessionId && getWs() && getWs().readyState === 1) {
560
+ getWs().send(JSON.stringify({ type: "switch_session", id: panePin.sessionId }));
561
+ }
562
+ }
552
563
  handlePaletteSessionSwitch();
553
564
  break;
554
565
 
566
+ case "split_groups":
567
+ store.set({ splitGroups: msg.groups || [] });
568
+ renderSessionList(null);
569
+ maybeRestoreSplitGroup();
570
+ syncPaneTitles();
571
+ break;
572
+
573
+ case "split_group_result":
574
+ if (!msg.ok && msg.error) showToast(msg.error, "error");
575
+ break;
576
+
577
+ case "pair_session_options":
578
+ showPairDialog(msg);
579
+ break;
580
+
581
+ case "pair_session_created":
582
+ var createdPairGroup = handlePairCreated(msg);
583
+ if (createdPairGroup) openGroup(createdPairGroup);
584
+ break;
585
+
586
+ case "split_delegation":
587
+ handleSplitDelegation(msg);
588
+ syncPaneTitles();
589
+ break;
590
+
555
591
  case "session_presence":
556
592
  updateSessionPresence(msg.presence || {});
557
593
  break;
@@ -611,11 +647,14 @@ export function processMessage(msg) {
611
647
  var _effectiveTerminalId = (typeof msg.runtimeTerminalId === "number")
612
648
  ? msg.runtimeTerminalId
613
649
  : (typeof msg.terminalId === "number" ? msg.terminalId : null);
614
- store.set({ activeSessionId: msg.id, cliSessionId: msg.cliSessionId || null, vendorCapabilities: msg.capabilities || {}, sessionIsProcessing: !!msg.isProcessing, activeSessionMode: _effectiveMode, activeTerminalId: _effectiveTerminalId, sessionHasHistory: !!msg.hasHistory });
650
+ store.set({ activeSessionId: msg.id, cliSessionId: msg.cliSessionId || null, currentModel: msg.model || store.get('currentModel'), currentEffort: msg.effort || store.get('currentEffort'), vendorCapabilities: msg.capabilities || {}, sessionIsProcessing: !!msg.isProcessing, activeSessionMode: _effectiveMode, activeTerminalId: _effectiveTerminalId, sessionHasHistory: !!msg.hasHistory, sessionFullAccess: msg.permissionMode === "bypassPermissions", currentMode: msg.permissionMode || store.get('currentMode') });
615
651
  // TUI sessions swap the chat UI for an embedded xterm running
616
652
  // `claude` inside a real PTY. Mount or tear down before the rest of
617
653
  // the chat-side bookkeeping runs so we don't waste work on hidden DOM.
618
- if (_effectiveMode === "tui" && typeof _effectiveTerminalId === "number") {
654
+ // With a split open the native app is hidden behind the pane iframes,
655
+ // but the TUI host is position:fixed on body -- attaching it here
656
+ // would overlay the split. Members render inside their own panes.
657
+ if (_effectiveMode === "tui" && typeof _effectiveTerminalId === "number" && !store.get('splitPanes')) {
619
658
  attachTuiView(_effectiveTerminalId, msg.id, msg.vendor || null);
620
659
  } else {
621
660
  detachTuiView();
@@ -624,9 +663,10 @@ export function processMessage(msg) {
624
663
  // (the composer is hidden; clicking Resume spawns claude --resume).
625
664
  setTuiSuspendedView(!!msg.tuiSuspended, msg.id);
626
665
  if (msg.vendor) {
627
- if (!store.get('vendorSelectionLocked') || msg.hasHistory) {
628
- store.set({ currentVendor: msg.vendor });
629
- }
666
+ // session_switched is authoritative. The lock rejects stale
667
+ // model_info responses; it must not block an intentional switch to
668
+ // a blank session owned by another vendor.
669
+ store.set({ currentVendor: resolveSwitchedVendor(store.get('currentVendor'), msg.vendor) });
630
670
  // Sessions with history have their vendor structurally bound to
631
671
  // the session: lock so a late-arriving default-adapter model_info
632
672
  // can't flip the UI back. Previously this branch unlocked, which
@@ -703,6 +743,19 @@ export function processMessage(msg) {
703
743
  if (!("ontouchstart" in window)) {
704
744
  inputEl.focus();
705
745
  }
746
+ // Reload survival: a restored active session that is a split-group
747
+ // member reopens its group (no-op when a split is already open).
748
+ maybeRestoreSplitGroup();
749
+ break;
750
+
751
+ case "session_full_access_changed":
752
+ if (msg.id === store.get('activeSessionId')) {
753
+ store.set({ sessionFullAccess: !!msg.enabled, currentMode: msg.permissionMode || "default" });
754
+ }
755
+ break;
756
+
757
+ case "session_full_access_result":
758
+ if (!msg.ok && msg.error) showToast(msg.error, "error");
706
759
  break;
707
760
 
708
761
  case "session_id":
@@ -733,7 +786,7 @@ export function processMessage(msg) {
733
786
  renderPlanCard(msg.planContent);
734
787
  addUserMessage("Execute the following plan. Do NOT re-enter plan mode — just implement it step by step.", msg.images || null, msg.pastes || null, msg.from, msg.fromName);
735
788
  } else {
736
- addUserMessage(msg.text, msg.images || null, msg.pastes || null, msg.from, msg.fromName);
789
+ addUserMessage(msg.text, msg.images || null, msg.pastes || null, msg.from, msg.fromName, msg.delegated ? msg : null);
737
790
  }
738
791
  break;
739
792
 
@@ -6,6 +6,7 @@ import { escapeHtml, showToast } from "./utils.js";
6
6
  import { store } from './store.js';
7
7
  import { getWs } from './ws-ref.js';
8
8
  import { VENDOR_NAMES } from './app-rendering.js';
9
+ import { reportPaneContext } from './pane-bridge.js';
9
10
 
10
11
  // --- Module-owned state (not in store) ---
11
12
  var sessionUsage = { cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 };
@@ -150,12 +151,30 @@ function modeDisplayName(value) {
150
151
  return value;
151
152
  }
152
153
 
153
- function effortDisplayName(value) {
154
+ export function effortDisplayName(value) {
154
155
  if (!value) return "";
155
156
  if (value === "xhigh") return "X-High";
156
157
  return value.charAt(0).toUpperCase() + value.slice(1);
157
158
  }
158
159
 
160
+ // Effort levels for an arbitrary vendor/model pair (pure; the pair dialog
161
+ // uses this for sessions other than the active one). Falls back from the
162
+ // model's own supportedEffortLevels to the vendor table.
163
+ export function effortLevelsFor(vendor, models, modelValue) {
164
+ var defaults = EFFORT_LEVELS_BY_VENDOR[vendor] || EFFORT_LEVELS;
165
+ var list = models || [];
166
+ for (var i = 0; i < list.length; i++) {
167
+ var value = typeof list[i] === "string" ? list[i] : (list[i] && (list[i].value || list[i].id)) || "";
168
+ if (value === modelValue) {
169
+ if (list[i].supportedEffortLevels && list[i].supportedEffortLevels.length > 0) {
170
+ return list[i].supportedEffortLevels;
171
+ }
172
+ return defaults;
173
+ }
174
+ }
175
+ return defaults;
176
+ }
177
+
159
178
  function thinkingDisplayName(value) {
160
179
  if (value === "disabled") return "Off";
161
180
  if (value === "adaptive") return "Adaptive";
@@ -179,22 +198,25 @@ function hasBeta(name) {
179
198
 
180
199
  function rebuildModelList() {
181
200
  if (!configModelList) return;
182
- // Picker visibility by vendor+mode:
183
- // Claude TUI -> shown, free (Claude TUI accepts mid-thread model swaps).
184
- // GUI (Claude SDK + Codex) -> shown, but locked once the session has
185
- // history. Both bind the model at session creation and
186
- // changing mid-thread breaks tool schemas / cache reuse,
187
- // so we allow the pick only before the first message.
188
- // sdk-bridge setModel stores into sm.currentModel when
189
- // there's no active queryInstance, so a pick made before
190
- // the first message takes effect on session start.
201
+ // Picker lock policy is capability-based (yoke midSessionModelSwitch):
202
+ // Claude -> free. The SDK supports mid-thread swaps on a live query
203
+ // (query.setModel: "Change the model used for subsequent
204
+ // responses"), and sdk-bridge already routes set_model to
205
+ // queryInstance.setModel. Cache re-priming on switch is a
206
+ // cost, not a breakage.
207
+ // Codex / Kiro -> locked once the session has history: their threads
208
+ // bind the model at creation and a mid-thread change would be
209
+ // silently ignored. Picks made before the first message still
210
+ // work everywhere (sdk-bridge stores into sm.currentModel when
211
+ // there's no active queryInstance).
191
212
  var modelSection = configModelList.parentElement;
192
213
  var s = store.snap();
193
214
  var vendor = s.currentVendor || "claude";
194
215
  if (modelSection) modelSection.style.display = "";
195
216
  configModelList.innerHTML = "";
196
217
 
197
- var lockedForGui = s.activeSessionMode === "gui" && !!s.sessionHasHistory;
218
+ var caps = s.vendorCapabilities || {};
219
+ var lockedForGui = s.activeSessionMode === "gui" && !!s.sessionHasHistory && !caps.midSessionModelSwitch;
198
220
 
199
221
  var list = s.currentModels.length > 0 ? s.currentModels : (s.currentModel ? [{ value: s.currentModel, displayName: s.currentModel }] : []);
200
222
  for (var i = 0; i < list.length; i++) {
@@ -210,7 +232,7 @@ function rebuildModelList() {
210
232
  btn.textContent = label;
211
233
  if (lockedForGui) {
212
234
  btn.disabled = true;
213
- btn.title = "Model is locked after the first message in a GUI session. Start a new session to change it.";
235
+ btn.title = "This vendor binds the model when the session starts. Start a new session to change it.";
214
236
  } else {
215
237
  btn.addEventListener("click", function () {
216
238
  var model = this.dataset.model;
@@ -228,7 +250,7 @@ function rebuildModelList() {
228
250
  if (lockedForGui) {
229
251
  var hint = document.createElement("div");
230
252
  hint.className = "config-model-hint";
231
- hint.textContent = "Locked after first message start a new session to change.";
253
+ hint.textContent = "Locked after first message. Start a new session to change models.";
232
254
  configModelList.appendChild(hint);
233
255
  }
234
256
  }
@@ -471,6 +493,9 @@ export function initPanels() {
471
493
  }
472
494
  updateContextPanel();
473
495
  }
496
+ // Split opened/closed -> show or hide the title-bar context gauge
497
+ if (state.splitPanes !== prev.splitPanes) updateContextPanel();
498
+
474
499
  // Vendor changed -> switch model list and current model to match
475
500
  if (state.currentVendor !== prev.currentVendor && state.currentVendor) {
476
501
  var ws = getWs();
@@ -670,18 +695,7 @@ export function getModelSupportsEffort() {
670
695
 
671
696
  export function getModelEffortLevels() {
672
697
  var s = store.snap();
673
- var vendor = s.currentVendor || "claude";
674
- var defaultLevels = EFFORT_LEVELS_BY_VENDOR[vendor] || EFFORT_LEVELS;
675
- if (!s.currentModels || s.currentModels.length === 0) return defaultLevels;
676
- for (var i = 0; i < s.currentModels.length; i++) {
677
- if (s.currentModels[i].value === s.currentModel) {
678
- if (s.currentModels[i].supportedEffortLevels && s.currentModels[i].supportedEffortLevels.length > 0) {
679
- return s.currentModels[i].supportedEffortLevels;
680
- }
681
- return defaultLevels;
682
- }
683
- }
684
- return defaultLevels;
698
+ return effortLevelsFor(s.currentVendor || "claude", s.currentModels || [], s.currentModel);
685
699
  }
686
700
 
687
701
  // --- Usage panel ---
@@ -807,6 +821,7 @@ export function updateContextPanel() {
807
821
  var win = contextData.contextWindow;
808
822
  var pct = win > 0 ? Math.min(100, (used / win) * 100) : 0;
809
823
  var cls = contextPctClass(pct);
824
+ reportPaneContext({ pct: pct, used: used, win: win, cls: cls, model: contextData.model, cost: contextData.cost });
810
825
  // Panel bar
811
826
  contextBarFill.style.width = pct.toFixed(1) + "%";
812
827
  contextBarFill.className = "context-bar-fill" + cls;
@@ -819,8 +834,15 @@ export function updateContextPanel() {
819
834
  if (contextMiniLabel) {
820
835
  contextMiniLabel.textContent = (win > 0 ? formatTokens(used) + "/" + formatTokens(win) : "0%");
821
836
  }
822
- // Header bar
823
- if (pct > 0) {
837
+ // Header bar. A split group has no meaningful group-level context usage:
838
+ // the two members have separate windows, and the parent shell's own numbers
839
+ // are whatever it last viewed natively. Each pane header carries its own
840
+ // chip instead, so hide the title-bar gauge while a split is open.
841
+ var splitOpen = !!store.get('splitPanes');
842
+ var headerCtxEl = store.get('headerContextEl');
843
+ if (headerCtxEl) headerCtxEl.classList.toggle("hidden", splitOpen);
844
+ if (contextMini) contextMini.classList.toggle("split-hidden", splitOpen);
845
+ if (pct > 0 && !splitOpen) {
824
846
  var statusArea = document.querySelector(".title-bar-content .status");
825
847
  var hCtxEl = store.get('headerContextEl');
826
848
  if (statusArea && !hCtxEl) {
@@ -416,12 +416,14 @@ export function finalizeAssistantBlock() {
416
416
  store.set({ currentMsgEl: null, currentFullText: "" });
417
417
  }
418
418
 
419
- export function addUserMessage(text, images, pastes, fromUserId, fromUserName) {
419
+ export function addUserMessage(text, images, pastes, fromUserId, fromUserName, delegatedMeta) {
420
420
  if (!text && (!images || images.length === 0) && (!pastes || pastes.length === 0)) return;
421
421
  var myUserId = store.get('myUserId');
422
- var isOtherUser = fromUserId && fromUserId !== myUserId;
422
+ var isDelegated = !!delegatedMeta;
423
+ var isOtherUser = isDelegated || (fromUserId && fromUserId !== myUserId);
423
424
  var div = document.createElement("div");
424
425
  div.className = "msg-user" + (isOtherUser ? " msg-user-other" : "");
426
+ if (isDelegated) div.classList.add("msg-user-delegated");
425
427
  div.dataset.turn = ++turnCounter;
426
428
  if (shouldGroupMessage("msg-user")) div.classList.add("grouped");
427
429
  var bubble = document.createElement("div");
@@ -482,7 +484,10 @@ export function addUserMessage(text, images, pastes, fromUserId, fromUserName) {
482
484
  var cachedAllUsers = store.get('cachedAllUsers');
483
485
  var _targetUser;
484
486
  var _displayName;
485
- if (isOtherUser) {
487
+ if (isDelegated) {
488
+ _targetUser = null;
489
+ _displayName = "from " + (delegatedMeta.delegatedByTitle || "Driver");
490
+ } else if (isOtherUser) {
486
491
  _targetUser = cachedAllUsers.find(function (u) { return u.id === fromUserId; });
487
492
  _displayName = fromUserName || (_targetUser && (_targetUser.displayName || _targetUser.username)) || "User";
488
493
  } else {
@@ -498,7 +503,9 @@ export function addUserMessage(text, images, pastes, fromUserId, fromUserName) {
498
503
 
499
504
  var avi = document.createElement("img");
500
505
  avi.className = "dm-bubble-avatar" + (isOtherUser ? " dm-bubble-avatar-other" : " dm-bubble-avatar-me");
501
- avi.src = isOtherUser
506
+ avi.src = isDelegated
507
+ ? (VENDOR_AVATARS[delegatedMeta.delegatedByVendor] || VENDOR_AVATARS.claude)
508
+ : isOtherUser
502
509
  ? userAvatarUrl(_targetUser || { id: fromUserId }, 36)
503
510
  : (document.body.dataset.myAvatarUrl || userAvatarUrl(_targetUser || { id: myUserId }, 36));
504
511
  div.appendChild(avi);
@@ -0,0 +1,41 @@
1
+ // Pane-side postMessage bridge for the split-view shell. A chrome-less pane
2
+ // iframe reports its context usage up to the parent (which renders a chip in
3
+ // the pane header) and honors panel-toggle requests coming back down.
4
+ // Same-origin messages only, both directions.
5
+
6
+ import { store } from './store.js';
7
+ import { applyContextView, getContextView } from './app-panels.js';
8
+
9
+ var panelOpen = false;
10
+
11
+ export function reportPaneContext(data) {
12
+ if (!store.get('paneMode') || window.parent === window) return;
13
+ window.parent.postMessage({
14
+ type: "clay-pane-context",
15
+ sessionId: store.get('activeSessionId'),
16
+ pct: data.pct,
17
+ used: data.used,
18
+ win: data.win,
19
+ cls: data.cls,
20
+ model: data.model,
21
+ cost: data.cost,
22
+ }, window.location.origin);
23
+ }
24
+
25
+ // Toggle without setContextView: panes share localStorage with the main app,
26
+ // so persisting here would silently rewrite the user's main-view preference.
27
+ function togglePaneContextPanel() {
28
+ panelOpen = !panelOpen;
29
+ applyContextView(panelOpen ? "panel" : "off");
30
+ }
31
+
32
+ export function initPaneBridge() {
33
+ if (!store.get('paneMode')) return;
34
+ panelOpen = getContextView() === "panel";
35
+ window.addEventListener("message", function (event) {
36
+ if (event.origin !== window.location.origin) return;
37
+ var msg = event.data;
38
+ if (!msg || msg.type !== "clay-pane-toggle-context") return;
39
+ togglePaneContextPanel();
40
+ });
41
+ }
@@ -0,0 +1,18 @@
1
+ // Pure resolution for the one-shot session pin used by pane-mode clients.
2
+
3
+ export function resolvePaneSession(paneMode, pinPending, pendingSessionId, sessions) {
4
+ if (!paneMode || !pinPending || !pendingSessionId) return { consumed: false, sessionId: null };
5
+ var list = sessions || [];
6
+ for (var i = 0; i < list.length; i++) {
7
+ if (list[i].id === pendingSessionId) {
8
+ return { consumed: true, sessionId: pendingSessionId };
9
+ }
10
+ }
11
+ return { consumed: true, sessionId: null };
12
+ }
13
+
14
+ // session_switched is authoritative even when the previous session left the
15
+ // vendor selector locked. The lock only applies to late model metadata.
16
+ export function resolveSwitchedVendor(currentVendor, sessionVendor) {
17
+ return sessionVendor || currentVendor || "claude";
18
+ }
@@ -13,6 +13,9 @@ import { showConfirm } from './app-misc.js';
13
13
  import { getUpcomingSchedules } from './scheduler.js';
14
14
  import { refreshMobileChatSheet } from './sidebar-mobile.js';
15
15
  import { VENDOR_AVATARS, VENDOR_NAMES, VENDOR_ORDER, VENDOR_HOMEPAGES } from './app-rendering.js';
16
+ import { openGroup, separateGroup } from './split-view.js';
17
+ import { groupedSessionIds } from './split-group-helpers.js';
18
+ import { openPairDialog } from './split-pair-ui.js';
16
19
 
17
20
 
18
21
  // --- Session state ---
@@ -230,6 +233,9 @@ function collectItemSessionIds(item) {
230
233
  }
231
234
  return ids;
232
235
  }
236
+ if (item.type === "split-group" && Array.isArray(item.members)) {
237
+ return item.members.map(function (session) { return session.id; });
238
+ }
233
239
  return [];
234
240
  }
235
241
 
@@ -407,6 +413,23 @@ function showNewSessionMenu(anchorBtn, defaultVendor) {
407
413
  })(VENDOR_ORDER[i]);
408
414
  }
409
415
 
416
+ if (installed.length > 0) {
417
+ var pairSep = document.createElement("div");
418
+ pairSep.className = "session-ctx-sep";
419
+ menu.appendChild(pairSep);
420
+ var pairItem = document.createElement("button");
421
+ pairItem.className = "session-ctx-item";
422
+ pairItem.type = "button";
423
+ pairItem.innerHTML = iconHtml("panel-left-close") + " <span>Pair session…</span>";
424
+ pairItem.title = "Create a Driver and Worker in a split view";
425
+ pairItem.addEventListener("click", function (e) {
426
+ e.stopPropagation();
427
+ closeSessionCtxMenu();
428
+ openPairDialog();
429
+ });
430
+ menu.appendChild(pairItem);
431
+ }
432
+
410
433
  // Claude-only launch mode: a bare TUI shell with permission prompts off.
411
434
  // Hidden in GUI mode (the default) because the item only ever produces a
412
435
  // terminal session, which contradicts what the rest of the menu creates.
@@ -418,7 +441,7 @@ function showNewSessionMenu(anchorBtn, defaultVendor) {
418
441
  var skipItem = document.createElement("button");
419
442
  skipItem.className = "session-ctx-item";
420
443
  skipItem.type = "button";
421
- skipItem.innerHTML = iconHtml("shield-off") + " <span>Skip permissions (TUI)</span>";
444
+ skipItem.innerHTML = iconHtml("shield-off") + " <span>Skip Permissions (TUI)</span>";
422
445
  skipItem.title = "Start a terminal session with --dangerously-skip-permissions";
423
446
  skipItem.addEventListener("click", function (e) {
424
447
  e.stopPropagation();
@@ -590,6 +613,7 @@ export function initSidebarSessions() {
590
613
  if (state.lastVendor !== prev.lastVendor || state.installedVendors !== prev.installedVendors) {
591
614
  renderSessionList(null);
592
615
  }
616
+ if (state.splitPanes !== prev.splitPanes) renderSessionList(null);
593
617
  });
594
618
 
595
619
  // --- Resume session picker ---
@@ -656,6 +680,7 @@ function showSessionCtxMenu(anchorBtn, sessionId, title, cliSid, sessionData) {
656
680
  });
657
681
  menu.appendChild(renameItem);
658
682
 
683
+
659
684
  // Session visibility toggle (only the session owner can change)
660
685
  if (store.get('isMultiUserMode') && sessionData && sessionData.ownerId && sessionData.ownerId === store.get('myUserId')) {
661
686
  var currentVis = (sessionData && sessionData.sessionVisibility) || "shared";
@@ -705,6 +730,45 @@ function showSessionCtxMenu(anchorBtn, sessionId, title, cliSid, sessionData) {
705
730
  });
706
731
  }
707
732
 
733
+ function showSplitGroupCtxMenu(anchor, group) {
734
+ closeSessionCtxMenu();
735
+ var menu = document.createElement("div");
736
+ menu.className = "session-ctx-menu";
737
+
738
+ var renameItem = document.createElement("button");
739
+ renameItem.className = "session-ctx-item";
740
+ renameItem.innerHTML = iconHtml("pencil") + " <span>Rename</span>";
741
+ renameItem.addEventListener("click", function (e) {
742
+ e.stopPropagation();
743
+ closeSessionCtxMenu();
744
+ startSplitGroupInlineRename(group);
745
+ });
746
+ menu.appendChild(renameItem);
747
+
748
+ var separateItem = document.createElement("button");
749
+ separateItem.className = "session-ctx-item";
750
+ separateItem.innerHTML = iconHtml("columns-2") + " <span>Separate</span>";
751
+ separateItem.addEventListener("click", function (e) {
752
+ e.stopPropagation();
753
+ closeSessionCtxMenu();
754
+ separateGroup(group);
755
+ });
756
+ menu.appendChild(separateItem);
757
+
758
+ document.body.appendChild(menu);
759
+ sessionCtxMenu = menu;
760
+ refreshIcons();
761
+ requestAnimationFrame(function () {
762
+ var rect = anchor.getBoundingClientRect();
763
+ menu.style.position = "fixed";
764
+ menu.style.top = (rect.bottom + 2) + "px";
765
+ menu.style.right = (window.innerWidth - rect.right) + "px";
766
+ menu.style.left = "auto";
767
+ var menuRect = menu.getBoundingClientRect();
768
+ if (menuRect.bottom > window.innerHeight - 8) menu.style.top = (rect.top - menuRect.height - 2) + "px";
769
+ });
770
+ }
771
+
708
772
  function showLoopCtxMenu(anchorBtn, loopId, loopName, childCount) {
709
773
  closeSessionCtxMenu();
710
774
 
@@ -796,6 +860,40 @@ function startInlineRename(sessionId, currentTitle) {
796
860
  input.addEventListener("click", function (e) { e.stopPropagation(); });
797
861
  }
798
862
 
863
+ function startSplitGroupInlineRename(group) {
864
+ var el = getSessionListEl().querySelector('.split-group-item[data-split-group-id="' + group.id + '"]');
865
+ if (!el) return;
866
+ var textSpan = el.querySelector(".split-group-name");
867
+ if (!textSpan) return;
868
+ var input = document.createElement("input");
869
+ input.type = "text";
870
+ input.className = "session-rename-input";
871
+ input.value = group.name || "Split";
872
+ var original = textSpan.textContent;
873
+ textSpan.innerHTML = "";
874
+ textSpan.appendChild(input);
875
+ input.focus();
876
+ input.select();
877
+ var finished = false;
878
+ function finish(commit) {
879
+ if (finished) return;
880
+ finished = true;
881
+ var name = input.value.trim();
882
+ if (commit && name && name !== group.name && getWs() && store.get('connected')) {
883
+ getWs().send(JSON.stringify({ type: "split_group_rename", id: group.id, name: name }));
884
+ textSpan.textContent = name;
885
+ } else {
886
+ textSpan.textContent = original;
887
+ }
888
+ }
889
+ input.addEventListener("keydown", function (e) {
890
+ if (e.key === "Enter") { e.preventDefault(); finish(true); }
891
+ if (e.key === "Escape") { e.preventDefault(); finish(false); }
892
+ });
893
+ input.addEventListener("blur", function () { finish(true); });
894
+ input.addEventListener("click", function (e) { e.stopPropagation(); });
895
+ }
896
+
799
897
  function startLoopInlineRename(loopId, currentName) {
800
898
  var el = getSessionListEl().querySelector('.session-loop-group[data-loop-id="' + loopId + '"]');
801
899
  if (!el) return;
@@ -1207,6 +1305,48 @@ function renderSessionItem(s) {
1207
1305
  return el;
1208
1306
  }
1209
1307
 
1308
+ function renderSplitGroupItem(group, members) {
1309
+ var el = document.createElement("div");
1310
+ var split = store.get('splitPanes');
1311
+ el.className = "session-item split-group-item" + (split && split.groupId === group.id ? " active" : "");
1312
+ el.dataset.splitGroupId = group.id;
1313
+
1314
+ var text = document.createElement("span");
1315
+ text.className = "session-item-text split-group-text";
1316
+ var html = '<span class="split-group-glyph" aria-hidden="true">' + iconHtml("columns-2") + '</span><span class="split-group-vendors">';
1317
+ for (var i = 0; i < members.length; i++) {
1318
+ var vendor = members[i].vendor || "claude";
1319
+ html += '<img class="session-vendor-icon split-group-vendor" src="' +
1320
+ (VENDOR_AVATARS[vendor] || VENDOR_AVATARS.claude) + '" alt="" title="' +
1321
+ escapeHtml(VENDOR_NAMES[vendor] || vendor) + '">';
1322
+ }
1323
+ html += "</span>";
1324
+ if (members[0].isProcessing || members[1].isProcessing) html += '<span class="session-processing"></span>';
1325
+ html += '<span class="split-group-name">' + highlightMatch(group.name || "Split", searchQuery) + "</span>";
1326
+ text.innerHTML = html;
1327
+ el.appendChild(text);
1328
+
1329
+ var unread = (members[0].unread || 0) + (members[1].unread || 0);
1330
+ var badge = document.createElement("span");
1331
+ badge.className = "session-unread-badge" + (unread > 0 ? " has-unread" : "");
1332
+ badge.dataset.splitGroupId = group.id;
1333
+ if (unread > 0) badge.textContent = unread > 99 ? "99+" : String(unread);
1334
+ el.appendChild(badge);
1335
+
1336
+ el.addEventListener("click", function () {
1337
+ if (openGroup(group)) {
1338
+ dismissOverlayPanels();
1339
+ closeSidebar();
1340
+ }
1341
+ });
1342
+ el.addEventListener("contextmenu", function (e) {
1343
+ e.preventDefault();
1344
+ e.stopPropagation();
1345
+ showSplitGroupCtxMenu(el, group);
1346
+ });
1347
+ return el;
1348
+ }
1349
+
1210
1350
  // --- Main session list ---
1211
1351
 
1212
1352
  export function renderSessionList(sessions) {
@@ -1219,10 +1359,15 @@ export function renderSessionList(sessions) {
1219
1359
 
1220
1360
  // Partition: loop sessions vs normal sessions
1221
1361
  // Group by loopId + date so all runs of the same task on the same day are merged
1362
+ var splitGroups = store.get('splitGroups') || [];
1363
+ var groupedIds = groupedSessionIds(splitGroups);
1364
+ var sessionsById = new Map();
1365
+ for (var si = 0; si < cachedSessions.length; si++) sessionsById.set(cachedSessions[si].id, cachedSessions[si]);
1222
1366
  var loopGroups = {}; // groupKey -> [sessions]
1223
1367
  var normalSessions = [];
1224
1368
  for (var i = 0; i < cachedSessions.length; i++) {
1225
1369
  var s = cachedSessions[i];
1370
+ if (groupedIds.has(s.id)) continue;
1226
1371
  if (s.loop && s.loop.loopId && s.loop.role === "crafting" && s.loop.source !== "ralph" && s.loop.source !== "debate") {
1227
1372
  // Task crafting sessions live in the scheduler calendar, not the main list (except debate)
1228
1373
  continue;
@@ -1242,6 +1387,18 @@ export function renderSessionList(sessions) {
1242
1387
  for (var j = 0; j < normalSessions.length; j++) {
1243
1388
  items.push({ type: "session", data: normalSessions[j], lastActivity: normalSessions[j].lastActivity || 0 });
1244
1389
  }
1390
+ for (var sg = 0; sg < splitGroups.length; sg++) {
1391
+ var splitGroup = splitGroups[sg];
1392
+ var leftMember = sessionsById.get(splitGroup.members[0]);
1393
+ var rightMember = sessionsById.get(splitGroup.members[1]);
1394
+ if (!leftMember || !rightMember) continue;
1395
+ items.push({
1396
+ type: "split-group",
1397
+ group: splitGroup,
1398
+ members: [leftMember, rightMember],
1399
+ lastActivity: Math.max(leftMember.lastActivity || 0, rightMember.lastActivity || 0),
1400
+ });
1401
+ }
1245
1402
  var groupKeys = Object.keys(loopGroups);
1246
1403
  for (var k = 0; k < groupKeys.length; k++) {
1247
1404
  var gk = groupKeys[k];
@@ -1265,6 +1422,8 @@ export function renderSessionList(sessions) {
1265
1422
  if (item.type === "session" && item.data && !isSessionVisibleBySearch(item.data.id)) {
1266
1423
  continue;
1267
1424
  }
1425
+ if (item.type === "split-group" && searchMatchIds !== null &&
1426
+ !searchMatchIds.has(item.members[0].id) && !searchMatchIds.has(item.members[1].id)) continue;
1268
1427
  if (item.type === "session" && item.data && item.data.bookmarked) {
1269
1428
  bookmarkedItems.push(item);
1270
1429
  } else {
@@ -1320,6 +1479,8 @@ export function renderSessionList(sessions) {
1320
1479
  if (loopEl) {
1321
1480
  regularContainer.appendChild(loopEl);
1322
1481
  }
1482
+ } else if (item.type === "split-group") {
1483
+ regularContainer.appendChild(renderSplitGroupItem(item.group, item.members));
1323
1484
  } else {
1324
1485
  regularContainer.appendChild(renderSessionItem(item.data));
1325
1486
  }
@@ -1400,7 +1561,20 @@ function renderPresenceAvatars(el, sessionId) {
1400
1561
  // --- Session badge ---
1401
1562
 
1402
1563
  export function updateSessionBadge(sessionId, count) {
1564
+ for (var i = 0; i < cachedSessions.length; i++) {
1565
+ if (cachedSessions[i].id === sessionId) cachedSessions[i].unread = count;
1566
+ }
1403
1567
  var badge = document.querySelector('.session-unread-badge[data-session-id="' + sessionId + '"]');
1568
+ var groups = store.get('splitGroups') || [];
1569
+ for (var gi = 0; gi < groups.length; gi++) {
1570
+ if (groups[gi].members.indexOf(sessionId) === -1) continue;
1571
+ badge = document.querySelector('.session-unread-badge[data-split-group-id="' + groups[gi].id + '"]');
1572
+ count = 0;
1573
+ for (var mi = 0; mi < cachedSessions.length; mi++) {
1574
+ if (groups[gi].members.indexOf(cachedSessions[mi].id) !== -1) count += cachedSessions[mi].unread || 0;
1575
+ }
1576
+ break;
1577
+ }
1404
1578
  if (!badge) return;
1405
1579
  if (count > 0) {
1406
1580
  badge.textContent = count > 99 ? "99+" : String(count);
@@ -12,6 +12,7 @@ import {
12
12
  getCurrentDmUserId
13
13
  } from './sidebar-mates.js';
14
14
  import { initSidebarMobile } from './sidebar-mobile.js';
15
+ import { store } from './store.js';
15
16
 
16
17
  var ctx;
17
18
  var _syncResizeHandles = null;
@@ -26,6 +27,7 @@ export function dismissOverlayPanels() {
26
27
  }
27
28
 
28
29
  export function updatePageTitle() {
30
+ if (store.get('paneMode')) return;
29
31
  var sessionTitle = "";
30
32
  var activeItem = ctx.sessionListEl.querySelector(".session-item.active .session-item-text");
31
33
  if (activeItem) sessionTitle = activeItem.textContent;