clay-server 4.3.0-beta.4 → 4.3.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/driver-continuation-pair.js +5 -1
- package/lib/multi-worker-feature.js +11 -0
- package/lib/project-connection.js +2 -0
- package/lib/project-pair-autonomous-stop.js +14 -8
- package/lib/project-pair-close-transaction.js +59 -0
- package/lib/project-pair-global-stop.js +18 -0
- package/lib/project-pair-lifecycle-status.js +34 -0
- package/lib/project-pair-lifecycle.js +58 -76
- package/lib/project-pair-message.js +1 -1
- package/lib/project-pair-owned-stop.js +7 -4
- package/lib/project-pair-result-capture.js +116 -0
- package/lib/project-pair-result-delivery.js +140 -0
- package/lib/project-pair-result-outbox.js +400 -0
- package/lib/project-pair-result-pipeline.js +16 -0
- package/lib/project-pair-result-recovery.js +229 -0
- package/lib/project-pair-structural-close.js +20 -0
- package/lib/project-pair-task-control.js +45 -18
- package/lib/project-pair-worker-close.js +68 -0
- package/lib/project-session-pair-prompt.js +26 -0
- package/lib/project-session-pair.js +87 -106
- package/lib/project-worker-creation-proposal.js +176 -0
- package/lib/project-worker-permission.js +7 -8
- package/lib/project-worker-proposal-runner.js +45 -0
- package/lib/project-worker-proposal.js +79 -115
- package/lib/project.js +38 -1
- package/lib/public/app.js +1 -0
- package/lib/public/css/messages.css +12 -0
- package/lib/public/css/pane.css +48 -2
- package/lib/public/modules/app-connection.js +49 -76
- package/lib/public/modules/app-messages.js +11 -0
- package/lib/public/modules/pair-result-status.js +87 -0
- package/lib/public/modules/project-activation.js +10 -7
- package/lib/public/modules/sidebar-sessions.js +31 -11
- package/lib/public/modules/split-group-helpers.js +74 -2
- package/lib/public/modules/split-pair-ui.js +28 -6
- package/lib/public/modules/split-pane-reconciler.js +28 -0
- package/lib/public/modules/split-pane-renderer.js +107 -0
- package/lib/public/modules/split-view.js +58 -188
- package/lib/public/modules/websocket-watchdog.js +126 -0
- package/lib/public/modules/worker-pane-lock.js +13 -3
- package/lib/server.js +1 -0
- package/lib/session-pair-factory.js +66 -0
- package/lib/session-pair-mcp-server.js +10 -5
- package/lib/session-pair-prompts.js +41 -25
- package/lib/session-pair-target.js +42 -0
- package/lib/session-pair-turn-control.js +11 -6
- package/lib/session-split-group-anchors.js +89 -7
- package/lib/session-split-group-persistence.js +136 -0
- package/lib/session-split-group-roles.js +164 -0
- package/lib/session-split-group-v2-store.js +146 -0
- package/lib/session-split-groups.js +109 -99
- package/lib/sessions.js +33 -2
- package/lib/ws-schema.js +2 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -3,20 +3,19 @@
|
|
|
3
3
|
import { store } from './store.js';
|
|
4
4
|
import { getWs } from './ws-ref.js';
|
|
5
5
|
import { getCachedSessions } from './sidebar-sessions.js';
|
|
6
|
-
import {
|
|
6
|
+
import { refreshIcons } from './icons.js';
|
|
7
7
|
import { detachTuiView } from './session-tui-view.js';
|
|
8
|
-
import {
|
|
9
|
-
import { VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
|
|
10
|
-
import { groupedSessionIds, findSplitGroup, isConfiguredWorker } from './split-group-helpers.js';
|
|
8
|
+
import { groupedSessionIds, findSplitGroup, isConfiguredWorker, splitGroupRoles, splitGroupMemberIds, splitGroupActiveAnchor, splitWorkerCloseRequest } from './split-group-helpers.js';
|
|
11
9
|
import { syncPairChrome } from './split-pair-ui.js';
|
|
12
10
|
import { presentMarkdownEdit } from './filebrowser.js';
|
|
13
11
|
import { autoStartLoginIfNeeded } from './vendor-login.js';
|
|
14
12
|
import { isCurrentProjectSessionReady } from './split-session-boundary.js';
|
|
15
|
-
import { splitPanesMatchProject,
|
|
13
|
+
import { splitPanesMatchProject, reconcileRestoredSplit } from './project-activation.js';
|
|
16
14
|
import { handlePaneIssueMessage } from './pane-issue-bridge.js';
|
|
17
15
|
import { handlePaneFileMessage, revealFilesPanel } from './pane-file-bridge.js';
|
|
18
16
|
import { openFile } from './filebrowser.js';
|
|
19
|
-
import {
|
|
17
|
+
import { reconcileSplitPanes } from './split-pane-reconciler.js';
|
|
18
|
+
import { createSplitPane, updateSplitPaneMetadata, updatePaneContextChip, updatePaneFullAccessButton } from './split-pane-renderer.js';
|
|
20
19
|
|
|
21
20
|
var host = null;
|
|
22
21
|
var nativeApp = null;
|
|
@@ -59,10 +58,6 @@ function paneForSession(sessionId) {
|
|
|
59
58
|
};
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
function paneUrl(pane) {
|
|
63
|
-
return "/p/" + encodeURIComponent(pane.slug) + "/?pane=1&session=" + encodeURIComponent(pane.sessionId);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
61
|
// Arc-style drop preview: hovering one half folds the live app into the
|
|
67
62
|
// other half and shows a ghost pane where the dragged session will land.
|
|
68
63
|
function setPreviewSide(side) {
|
|
@@ -99,48 +94,28 @@ function switchNativeSession(sessionId, expectedSlug) {
|
|
|
99
94
|
}
|
|
100
95
|
}
|
|
101
96
|
|
|
102
|
-
function closePane(
|
|
97
|
+
function closePane(sessionId) {
|
|
103
98
|
var split = store.get('splitPanes');
|
|
104
|
-
if (!split || !split.panes || split.panes.length
|
|
99
|
+
if (!split || !split.panes || split.panes.length < 2) return;
|
|
100
|
+
var index = -1;
|
|
101
|
+
for (var pi = 0; pi < split.panes.length; pi++) if (split.panes[pi].sessionId === sessionId) index = pi;
|
|
102
|
+
if (index === -1) return;
|
|
105
103
|
if (split.groupId && getWs() && getWs().readyState === 1) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
input.value = pane.title;
|
|
117
|
-
titleEl.style.display = "none";
|
|
118
|
-
header.insertBefore(input, titleEl);
|
|
119
|
-
input.focus();
|
|
120
|
-
input.select();
|
|
121
|
-
|
|
122
|
-
var done = false;
|
|
123
|
-
function finish(commit) {
|
|
124
|
-
if (done) return;
|
|
125
|
-
done = true;
|
|
126
|
-
var newTitle = input.value.trim();
|
|
127
|
-
input.remove();
|
|
128
|
-
titleEl.style.display = "";
|
|
129
|
-
if (!commit || !newTitle || newTitle === pane.title) return;
|
|
130
|
-
pane.title = newTitle;
|
|
131
|
-
titleEl.textContent = newTitle;
|
|
132
|
-
var ws = getWs();
|
|
133
|
-
if (ws && ws.readyState === 1) {
|
|
134
|
-
ws.send(JSON.stringify({ type: "rename_session", id: pane.sessionId, title: newTitle }));
|
|
104
|
+
var group = null;
|
|
105
|
+
var groups = store.get('splitGroups') || [];
|
|
106
|
+
for (var gi = 0; gi < groups.length; gi++) if (groups[gi].id === split.groupId) group = groups[gi];
|
|
107
|
+
var roles = splitGroupRoles(group);
|
|
108
|
+
var closingId = sessionId;
|
|
109
|
+
if (roles && roles.version === 2 && roles.workerIds.indexOf(closingId) !== -1) {
|
|
110
|
+
var request = splitWorkerCloseRequest(group, getCachedSessions(), store.get('currentSlug'), closingId,
|
|
111
|
+
"worker-close-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8));
|
|
112
|
+
if (request) getWs().send(JSON.stringify(request));
|
|
113
|
+
return;
|
|
135
114
|
}
|
|
115
|
+
getWs().send(JSON.stringify({ type: "split_group_dissolve", id: split.groupId }));
|
|
136
116
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (event.key === "Enter") { event.preventDefault(); finish(true); }
|
|
140
|
-
if (event.key === "Escape") { event.preventDefault(); finish(false); }
|
|
141
|
-
});
|
|
142
|
-
input.addEventListener("blur", function () { finish(true); });
|
|
143
|
-
input.addEventListener("click", function (event) { event.stopPropagation(); });
|
|
117
|
+
var next = split.panes[index === 0 ? 1 : 0];
|
|
118
|
+
switchNativeSession(next.sessionId, next.slug);
|
|
144
119
|
}
|
|
145
120
|
|
|
146
121
|
// Called on session_list so pane headers follow renames made elsewhere
|
|
@@ -163,117 +138,6 @@ export function syncPaneTitles() {
|
|
|
163
138
|
syncPairChrome(host, split);
|
|
164
139
|
}
|
|
165
140
|
|
|
166
|
-
function panePermissionControlState(session) {
|
|
167
|
-
var mode = session && (session.runtimeMode || session.mode || "gui");
|
|
168
|
-
var worker = !!session && isConfiguredWorker(store.get('splitGroups'), session.id);
|
|
169
|
-
return {
|
|
170
|
-
projectSlug: store.get('currentSlug'),
|
|
171
|
-
sessionId: session && session.id,
|
|
172
|
-
vendor: session && session.vendor || store.get('currentVendor') || "claude",
|
|
173
|
-
permissionMode: session && session.permissionMode || "default",
|
|
174
|
-
effectivePermissionMode: session && session.effectivePermissionMode || null,
|
|
175
|
-
permissionCapabilities: session && session.permissionCapabilities || { auto: false, mcpOverride: false },
|
|
176
|
-
connected: store.get('connected'),
|
|
177
|
-
globalPermissionModeForced: store.get('skipPermsEnabled') === true,
|
|
178
|
-
visible: !!session && !worker && mode === "gui",
|
|
179
|
-
locked: worker,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function updatePaneFullAccessButton(button, session) {
|
|
184
|
-
if (!button) return;
|
|
185
|
-
// Permission policy belongs to the Driver. A configured Worker inherits it
|
|
186
|
-
// server-side and must not present a second, misleading control.
|
|
187
|
-
renderPermissionControl(button, panePermissionControlState(session));
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function createPane(pane, index) {
|
|
191
|
-
var paneEl = document.createElement("section");
|
|
192
|
-
paneEl.className = "split-pane";
|
|
193
|
-
|
|
194
|
-
var header = document.createElement("header");
|
|
195
|
-
header.className = "split-pane-header";
|
|
196
|
-
header.title = pane.title;
|
|
197
|
-
|
|
198
|
-
var session = sessionById(pane.sessionId);
|
|
199
|
-
var vendor = (session && session.vendor) || "claude";
|
|
200
|
-
var vendorIcon = document.createElement("img");
|
|
201
|
-
vendorIcon.className = "split-pane-vendor";
|
|
202
|
-
vendorIcon.src = VENDOR_AVATARS[vendor] || VENDOR_AVATARS.claude;
|
|
203
|
-
vendorIcon.alt = "";
|
|
204
|
-
vendorIcon.title = VENDOR_NAMES[vendor] || vendor;
|
|
205
|
-
header.appendChild(vendorIcon);
|
|
206
|
-
|
|
207
|
-
var title = document.createElement("span");
|
|
208
|
-
title.className = "split-pane-title";
|
|
209
|
-
title.textContent = pane.title;
|
|
210
|
-
title.title = "Rename session";
|
|
211
|
-
title.addEventListener("click", function () { startPaneRename(header, title, pane); });
|
|
212
|
-
header.appendChild(title);
|
|
213
|
-
|
|
214
|
-
var frame = document.createElement("iframe");
|
|
215
|
-
frame.className = "split-pane-frame";
|
|
216
|
-
frame.dataset.projectSlug = pane.slug;
|
|
217
|
-
frame.dataset.sessionId = String(pane.sessionId);
|
|
218
|
-
frame.src = paneUrl(pane);
|
|
219
|
-
frame.title = pane.title;
|
|
220
|
-
|
|
221
|
-
// Context-usage chip, fed by clay-pane-context messages from the pane
|
|
222
|
-
// iframe. Hidden until the pane reports a non-zero context percentage.
|
|
223
|
-
var ctxChip = document.createElement("button");
|
|
224
|
-
ctxChip.type = "button";
|
|
225
|
-
ctxChip.className = "split-pane-context";
|
|
226
|
-
ctxChip.title = "Context usage";
|
|
227
|
-
ctxChip.innerHTML = '<span class="split-pane-context-bar"><span class="split-pane-context-fill"></span></span><span class="split-pane-context-label"></span>';
|
|
228
|
-
ctxChip.addEventListener("click", function () {
|
|
229
|
-
if (frame.contentWindow) {
|
|
230
|
-
frame.contentWindow.postMessage({ type: "clay-pane-toggle-context" }, window.location.origin);
|
|
231
|
-
}
|
|
232
|
-
});
|
|
233
|
-
header.appendChild(ctxChip);
|
|
234
|
-
|
|
235
|
-
var fullAccess = createPermissionControl("split-pane-full-access hidden", "Driver session permission mode");
|
|
236
|
-
updatePaneFullAccessButton(fullAccess, session);
|
|
237
|
-
bindPermissionControl(fullAccess, function() {
|
|
238
|
-
var current = sessionById(pane.sessionId);
|
|
239
|
-
return panePermissionControlState(current);
|
|
240
|
-
});
|
|
241
|
-
header.appendChild(fullAccess);
|
|
242
|
-
// Permission state belongs to the session identity on the left. Keep it
|
|
243
|
-
// immediately after the title instead of grouping it with usage and close.
|
|
244
|
-
header.insertBefore(fullAccess, ctxChip);
|
|
245
|
-
|
|
246
|
-
var close = document.createElement("button");
|
|
247
|
-
close.type = "button";
|
|
248
|
-
close.className = "split-pane-close";
|
|
249
|
-
close.title = "Close pane";
|
|
250
|
-
close.setAttribute("aria-label", "Close pane");
|
|
251
|
-
close.innerHTML = iconHtml("x");
|
|
252
|
-
close.addEventListener("click", function () { closePane(index); });
|
|
253
|
-
header.appendChild(close);
|
|
254
|
-
paneEl.appendChild(header);
|
|
255
|
-
paneEl.appendChild(frame);
|
|
256
|
-
return paneEl;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function updatePaneContextChip(paneEl, msg) {
|
|
260
|
-
if (!paneEl) return;
|
|
261
|
-
var chip = paneEl.querySelector(".split-pane-context");
|
|
262
|
-
if (!chip) return;
|
|
263
|
-
var pct = msg.pct || 0;
|
|
264
|
-
if (pct <= 0) return;
|
|
265
|
-
chip.classList.add("has-data");
|
|
266
|
-
var fill = chip.querySelector(".split-pane-context-fill");
|
|
267
|
-
var label = chip.querySelector(".split-pane-context-label");
|
|
268
|
-
fill.style.width = Math.min(100, pct).toFixed(1) + "%";
|
|
269
|
-
fill.className = "split-pane-context-fill" + (msg.cls || "");
|
|
270
|
-
label.textContent = pct.toFixed(0) + "%";
|
|
271
|
-
var tip = "Context " + pct.toFixed(0) + "% (" + formatTokens(msg.used || 0) + " / " + formatTokens(msg.win || 0) + " tokens)";
|
|
272
|
-
if (msg.cost) tip += " · $" + msg.cost.toFixed(4);
|
|
273
|
-
if (msg.model && msg.model !== "-") tip += " · " + msg.model;
|
|
274
|
-
chip.title = tip;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
141
|
function handlePaneMessage(event) {
|
|
278
142
|
if (event.origin !== window.location.origin) return;
|
|
279
143
|
var msg = event.data;
|
|
@@ -301,34 +165,20 @@ function handlePaneMessage(event) {
|
|
|
301
165
|
}
|
|
302
166
|
}
|
|
303
167
|
|
|
304
|
-
var renderedPaneKey = null;
|
|
305
|
-
|
|
306
|
-
function paneKey(panes) {
|
|
307
|
-
return panes.map(function (pane) { return pane.slug + "#" + pane.sessionId; }).join("|");
|
|
308
|
-
}
|
|
309
|
-
|
|
310
168
|
function renderSplit(split) {
|
|
311
169
|
if (!host || !nativeApp) return;
|
|
312
170
|
var panes = split && split.panes;
|
|
313
|
-
if (!panes || panes.length
|
|
171
|
+
if (!panes || panes.length < 2 || panes.length > 3) {
|
|
314
172
|
placeStickyNotesOverlay(false);
|
|
315
173
|
host.innerHTML = "";
|
|
316
|
-
renderedPaneKey = null;
|
|
317
174
|
host.classList.remove("visible");
|
|
318
175
|
nativeApp.classList.remove("split-native-hidden");
|
|
319
176
|
return;
|
|
320
177
|
}
|
|
321
|
-
// splitPanes is replaced (same panes, new object) when the server confirms
|
|
322
|
-
// the groupId. Rebuilding then would reload both iframes, so skip DOM work
|
|
323
|
-
// when the rendered pane set is unchanged.
|
|
324
|
-
var key = paneKey(panes);
|
|
325
178
|
placeStickyNotesOverlay(true);
|
|
326
|
-
if (key === renderedPaneKey && host.classList.contains("visible")) return;
|
|
327
|
-
host.innerHTML = "";
|
|
328
|
-
renderedPaneKey = key;
|
|
329
179
|
nativeApp.classList.add("split-native-hidden");
|
|
330
180
|
host.classList.add("visible");
|
|
331
|
-
|
|
181
|
+
reconcileSplitPanes(host, panes, function (pane) { return createSplitPane(pane, closePane); }, updateSplitPaneMetadata);
|
|
332
182
|
syncPairChrome(host, split);
|
|
333
183
|
refreshIcons();
|
|
334
184
|
}
|
|
@@ -360,23 +210,26 @@ function openSplit(side, draggedId) {
|
|
|
360
210
|
}
|
|
361
211
|
|
|
362
212
|
export function openGroup(group) {
|
|
363
|
-
if (!group || !Array.isArray(group.members) || group.members.length
|
|
364
|
-
|
|
213
|
+
if (!group || !Array.isArray(group.members) || group.members.length < 2 || group.members.length > 3) return false;
|
|
214
|
+
var memberIds = splitGroupMemberIds(group);
|
|
215
|
+
if (memberIds.length !== group.members.length) return false;
|
|
216
|
+
for (var mi = 0; mi < memberIds.length; mi++) if (!sessionById(memberIds[mi])) return false;
|
|
365
217
|
detachTuiView();
|
|
366
218
|
store.set({
|
|
367
219
|
splitPanes: {
|
|
368
220
|
groupId: group.id,
|
|
369
|
-
panes:
|
|
221
|
+
panes: memberIds.map(function (id) { return paneForSession(id); }),
|
|
370
222
|
},
|
|
371
223
|
});
|
|
372
224
|
// Anchor the parent's active session (and server-side presence) to a
|
|
373
225
|
// member while the split is open, so a hard refresh restores into this
|
|
374
226
|
// group instead of whatever was viewed before it.
|
|
375
227
|
var activeId = store.get('activeSessionId');
|
|
376
|
-
|
|
228
|
+
var anchorId = splitGroupActiveAnchor(group, activeId);
|
|
229
|
+
if (anchorId && anchorId !== activeId) {
|
|
377
230
|
var ws = getWs();
|
|
378
231
|
if (isCurrentProjectSessionReady(store.snap(), ws)) {
|
|
379
|
-
ws.send(JSON.stringify({ type: "switch_session", id:
|
|
232
|
+
ws.send(JSON.stringify({ type: "switch_session", id: anchorId }));
|
|
380
233
|
}
|
|
381
234
|
}
|
|
382
235
|
dismissSplitOverlays();
|
|
@@ -403,13 +256,13 @@ export function maybeRestoreSplitGroup() {
|
|
|
403
256
|
}
|
|
404
257
|
if (reconciliation.action === "rebuild") {
|
|
405
258
|
var rebuilt = reconciliation.group;
|
|
406
|
-
if (
|
|
259
|
+
if (rebuilt.members.some(function (id) { return !sessionById(id); })) {
|
|
407
260
|
store.set({ splitPanes: null });
|
|
408
261
|
return;
|
|
409
262
|
}
|
|
410
263
|
store.set({ splitPanes: {
|
|
411
264
|
groupId: rebuilt.id,
|
|
412
|
-
panes:
|
|
265
|
+
panes: splitGroupMemberIds(rebuilt).map(function (id) { return paneForSession(id); }),
|
|
413
266
|
} });
|
|
414
267
|
}
|
|
415
268
|
return;
|
|
@@ -427,13 +280,13 @@ export function maybeRestoreSplitGroup() {
|
|
|
427
280
|
}
|
|
428
281
|
|
|
429
282
|
export function separateGroup(group) {
|
|
430
|
-
if (!group || !Array.isArray(group.members) || group.members.length
|
|
283
|
+
if (!group || !Array.isArray(group.members) || group.members.length < 2) return;
|
|
431
284
|
var ws = getWs();
|
|
432
285
|
if (ws && ws.readyState === 1) {
|
|
433
286
|
ws.send(JSON.stringify({ type: "split_group_dissolve", id: group.id }));
|
|
434
287
|
}
|
|
435
288
|
var split = store.get('splitPanes');
|
|
436
|
-
if (split && split.groupId === group.id) switchNativeSession(
|
|
289
|
+
if (split && split.groupId === group.id) switchNativeSession(split.panes[0].sessionId);
|
|
437
290
|
}
|
|
438
291
|
|
|
439
292
|
function dismissSplitOverlays() {
|
|
@@ -531,24 +384,41 @@ export function initSplitView() {
|
|
|
531
384
|
// notification click) closes the split UI; the group itself persists.
|
|
532
385
|
if (state.activeSessionId !== prev.activeSessionId && state.splitPanes && state.splitPanes.panes) {
|
|
533
386
|
var sp = state.splitPanes.panes;
|
|
534
|
-
|
|
387
|
+
var activePane = false;
|
|
388
|
+
for (var spi = 0; spi < sp.length; spi++) if (state.activeSessionId === sp[spi].sessionId) activePane = true;
|
|
389
|
+
if (!activePane) {
|
|
535
390
|
store.set({ splitPanes: null });
|
|
536
391
|
}
|
|
537
392
|
}
|
|
538
393
|
if (state.splitGroups !== prev.splitGroups) {
|
|
539
394
|
if (!isCurrentProjectSessionReady(state, getWs())) return;
|
|
540
395
|
var split = state.splitPanes;
|
|
541
|
-
if (!split || !split.panes) return;
|
|
396
|
+
if (!split || !split.panes || split.panes.length < 2) return;
|
|
542
397
|
if (!splitPanesMatchProject(split, state.currentSlug)) {
|
|
543
398
|
store.set({ splitPanes: null });
|
|
544
399
|
return;
|
|
545
400
|
}
|
|
546
401
|
syncPairChrome(host, split);
|
|
547
402
|
if (split.groupId) {
|
|
548
|
-
|
|
403
|
+
var currentGroup = null;
|
|
404
|
+
for (var cgi = 0; cgi < state.splitGroups.length; cgi++) {
|
|
405
|
+
if (state.splitGroups[cgi].id === split.groupId) currentGroup = state.splitGroups[cgi];
|
|
406
|
+
}
|
|
407
|
+
if (!currentGroup) {
|
|
408
|
+
switchNativeSession(split.panes[0].sessionId, state.currentSlug);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
var projectedIds = splitGroupMemberIds(currentGroup);
|
|
412
|
+
if (projectedIds.length < 2 || projectedIds.length > 3) return;
|
|
413
|
+
var projectedPanes = projectedIds.map(function (id) { return paneForSession(id); });
|
|
414
|
+
var projectedKey = projectedPanes.map(function (pane) { return pane.slug + "#" + pane.sessionId; }).join("|");
|
|
415
|
+
var currentKey = split.panes.map(function (pane) { return pane.slug + "#" + pane.sessionId; }).join("|");
|
|
416
|
+
if (projectedKey !== currentKey) {
|
|
417
|
+
store.set({ splitPanes: { groupId: split.groupId, panes: projectedPanes } });
|
|
418
|
+
}
|
|
549
419
|
return;
|
|
550
420
|
}
|
|
551
|
-
var ids =
|
|
421
|
+
var ids = split.panes.map(function (pane) { return pane.sessionId; });
|
|
552
422
|
var confirmed = findSplitGroup(state.splitGroups, ids);
|
|
553
423
|
if (confirmed) store.set({ splitPanes: { groupId: confirmed.id, panes: split.panes } });
|
|
554
424
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// websocket-watchdog.js - suspendable liveness timers for the browser socket
|
|
2
|
+
|
|
3
|
+
function createWebSocketWatchdog(options) {
|
|
4
|
+
var suspended = false;
|
|
5
|
+
var heartbeatTimer = null;
|
|
6
|
+
var heartbeatDeadlineTimer = null;
|
|
7
|
+
var heartbeatSocket = null;
|
|
8
|
+
var heartbeatEpoch = 0;
|
|
9
|
+
var heartbeatGeneration = 0;
|
|
10
|
+
var deadlineGeneration = 0;
|
|
11
|
+
var probeTimer = null;
|
|
12
|
+
var probeSocket = null;
|
|
13
|
+
var probeEpoch = 0;
|
|
14
|
+
var probeStartedAt = 0;
|
|
15
|
+
var probeGeneration = 0;
|
|
16
|
+
|
|
17
|
+
function clearHeartbeatDeadline() {
|
|
18
|
+
deadlineGeneration += 1;
|
|
19
|
+
if (heartbeatDeadlineTimer) clearTimeout(heartbeatDeadlineTimer);
|
|
20
|
+
heartbeatDeadlineTimer = null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function stopHeartbeat() {
|
|
24
|
+
heartbeatGeneration += 1;
|
|
25
|
+
clearHeartbeatDeadline();
|
|
26
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
27
|
+
heartbeatTimer = null;
|
|
28
|
+
heartbeatSocket = null;
|
|
29
|
+
heartbeatEpoch = 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function clearProbe() {
|
|
33
|
+
probeGeneration += 1;
|
|
34
|
+
if (probeTimer) clearTimeout(probeTimer);
|
|
35
|
+
probeTimer = null;
|
|
36
|
+
probeSocket = null;
|
|
37
|
+
probeEpoch = 0;
|
|
38
|
+
probeStartedAt = 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function suspend() {
|
|
42
|
+
suspended = true;
|
|
43
|
+
stopHeartbeat();
|
|
44
|
+
clearProbe();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resume() {
|
|
48
|
+
var changed = suspended;
|
|
49
|
+
suspended = false;
|
|
50
|
+
return changed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function startHeartbeat(socket, epoch) {
|
|
54
|
+
stopHeartbeat();
|
|
55
|
+
if (suspended || !socket) return false;
|
|
56
|
+
heartbeatSocket = socket;
|
|
57
|
+
heartbeatEpoch = epoch;
|
|
58
|
+
var generation = heartbeatGeneration;
|
|
59
|
+
heartbeatTimer = setInterval(function () {
|
|
60
|
+
if (suspended || generation !== heartbeatGeneration) return;
|
|
61
|
+
if (!options.isCurrent(socket, epoch)) {
|
|
62
|
+
stopHeartbeat();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
clearHeartbeatDeadline();
|
|
66
|
+
var currentDeadlineGeneration = deadlineGeneration;
|
|
67
|
+
heartbeatDeadlineTimer = setTimeout(function () {
|
|
68
|
+
if (suspended || currentDeadlineGeneration !== deadlineGeneration) return;
|
|
69
|
+
heartbeatDeadlineTimer = null;
|
|
70
|
+
if (heartbeatSocket === socket && heartbeatEpoch === epoch && options.isCurrent(socket, epoch)) {
|
|
71
|
+
options.onFailure("heartbeat_timeout", epoch, null);
|
|
72
|
+
}
|
|
73
|
+
}, options.heartbeatDeadlineMs);
|
|
74
|
+
try {
|
|
75
|
+
options.sendPing(socket);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
options.onFailure("heartbeat_error", epoch, null);
|
|
78
|
+
}
|
|
79
|
+
}, options.heartbeatIntervalMs);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function probe(socket, epoch) {
|
|
84
|
+
if (suspended) return "suspended";
|
|
85
|
+
if (probeTimer) return "pending";
|
|
86
|
+
probeSocket = socket;
|
|
87
|
+
probeEpoch = epoch;
|
|
88
|
+
probeStartedAt = options.now();
|
|
89
|
+
probeGeneration += 1;
|
|
90
|
+
var generation = probeGeneration;
|
|
91
|
+
probeTimer = setTimeout(function () {
|
|
92
|
+
if (suspended || generation !== probeGeneration) return;
|
|
93
|
+
probeTimer = null;
|
|
94
|
+
if (probeSocket === socket && probeEpoch === epoch && options.isCurrent(socket, epoch)) {
|
|
95
|
+
options.onFailure("probe_timeout", epoch, options.now() - probeStartedAt);
|
|
96
|
+
}
|
|
97
|
+
}, options.probeTimeoutMs);
|
|
98
|
+
try {
|
|
99
|
+
options.sendPing(socket);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (generation !== probeGeneration) return "cleared";
|
|
102
|
+
var latency = options.now() - probeStartedAt;
|
|
103
|
+
clearProbe();
|
|
104
|
+
options.onFailure("probe_error", epoch, latency);
|
|
105
|
+
return "failed";
|
|
106
|
+
}
|
|
107
|
+
return "started";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function acceptPong(socket, epoch) {
|
|
111
|
+
if (heartbeatSocket === socket && heartbeatEpoch === epoch) clearHeartbeatDeadline();
|
|
112
|
+
if (probeSocket === socket && probeEpoch === epoch) clearProbe();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
acceptPong: acceptPong,
|
|
117
|
+
clearProbe: clearProbe,
|
|
118
|
+
probe: probe,
|
|
119
|
+
resume: resume,
|
|
120
|
+
startHeartbeat: startHeartbeat,
|
|
121
|
+
stopHeartbeat: stopHeartbeat,
|
|
122
|
+
suspend: suspend
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export { createWebSocketWatchdog };
|
|
@@ -27,6 +27,18 @@ import { store } from './store.js';
|
|
|
27
27
|
var STATUS_ID = "worker-pane-status";
|
|
28
28
|
var LOCK_CLASS = "worker-pane-locked";
|
|
29
29
|
|
|
30
|
+
function configuredWorkerIds(group) {
|
|
31
|
+
if (!group || !group.pair || !Array.isArray(group.members)) return [];
|
|
32
|
+
var pair = group.pair;
|
|
33
|
+
var ids = Array.isArray(pair.workerIds) ? pair.workerIds :
|
|
34
|
+
(pair.workerId !== undefined ? [pair.workerId] : []);
|
|
35
|
+
if (pair.driverId === undefined || ids.length < 1 || ids.length > 2 || group.members.indexOf(pair.driverId) === -1) return [];
|
|
36
|
+
for (var i = 0; i < ids.length; i++) {
|
|
37
|
+
if (ids[i] === pair.driverId || group.members.indexOf(ids[i]) === -1) return [];
|
|
38
|
+
}
|
|
39
|
+
return ids;
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
// Every interactive composer affordance. Queried lazily: a pane iframe and the
|
|
31
43
|
// main window share this markup, and some ids are mobile-only.
|
|
32
44
|
var LOCKED_CONTROL_IDS = [
|
|
@@ -64,9 +76,7 @@ export function isDriverOperatedView(state) {
|
|
|
64
76
|
if (!sessionId) return false;
|
|
65
77
|
var groups = snapshot.splitGroups || [];
|
|
66
78
|
for (var i = 0; i < groups.length; i++) {
|
|
67
|
-
|
|
68
|
-
if (!pair) continue;
|
|
69
|
-
if (pair.workerId === sessionId) return true;
|
|
79
|
+
if (configuredWorkerIds(groups[i]).indexOf(sessionId) !== -1) return true;
|
|
70
80
|
}
|
|
71
81
|
return false;
|
|
72
82
|
}
|
package/lib/server.js
CHANGED
|
@@ -1336,6 +1336,7 @@ function createServer(opts) {
|
|
|
1336
1336
|
worktreeMeta: worktreeMeta || null,
|
|
1337
1337
|
mcpBridgeToken: extra.mcpBridgeToken || null,
|
|
1338
1338
|
isMate: extra.isMate || false,
|
|
1339
|
+
multiWorkerRuntimeEnabled: !extra.isMate,
|
|
1339
1340
|
mateId: extra.mateId || (extra.isMate ? path.basename(cwd) : null),
|
|
1340
1341
|
mateDisplayName: extra.mateDisplayName || "",
|
|
1341
1342
|
isHostAgent: !!extra.isHostAgent,
|