clay-server 5.2.0-beta.7 → 5.2.0-beta.9

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 (49) hide show
  1. package/lib/pane-pending-interactions.js +20 -0
  2. package/lib/pane-session-activation.js +114 -0
  3. package/lib/pane-session-transport.js +26 -0
  4. package/lib/project-sessions.js +15 -5
  5. package/lib/project.js +11 -3
  6. package/lib/public/app.js +14 -0
  7. package/lib/public/css/contextual-clay.css +1 -1
  8. package/lib/public/css/input.css +6 -0
  9. package/lib/public/css/sticky-notes.css +37 -11
  10. package/lib/public/modules/app-connection.js +3 -0
  11. package/lib/public/modules/app-message-router.js +13 -0
  12. package/lib/public/modules/app-messages.js +25 -5
  13. package/lib/public/modules/app-misc.js +3 -1
  14. package/lib/public/modules/app-projects.js +5 -0
  15. package/lib/public/modules/clay-owner-navigation.js +0 -3
  16. package/lib/public/modules/clay-studio-render.js +3 -1
  17. package/lib/public/modules/command-palette.js +52 -14
  18. package/lib/public/modules/contextual-clay-workspace.js +14 -2
  19. package/lib/public/modules/contextual-clay.js +16 -3
  20. package/lib/public/modules/contextual-mate-context.js +6 -4
  21. package/lib/public/modules/elicitation-form.js +5 -0
  22. package/lib/public/modules/history-replay-batch.js +8 -0
  23. package/lib/public/modules/input.js +55 -11
  24. package/lib/public/modules/message-delivery.js +10 -2
  25. package/lib/public/modules/model-picker.js +2 -0
  26. package/lib/public/modules/pane-activation.js +214 -0
  27. package/lib/public/modules/pane-binding.js +82 -0
  28. package/lib/public/modules/pane-bridge.js +5 -2
  29. package/lib/public/modules/pane-browser-bridge.js +5 -3
  30. package/lib/public/modules/pane-file-bridge.js +4 -0
  31. package/lib/public/modules/pane-issue-bridge.js +4 -1
  32. package/lib/public/modules/rewind.js +5 -0
  33. package/lib/public/modules/session-actions.js +5 -2
  34. package/lib/public/modules/session-context-menu.js +5 -0
  35. package/lib/public/modules/session-rich-drafts.js +21 -0
  36. package/lib/public/modules/session-switch-defer.js +67 -0
  37. package/lib/public/modules/split-group-restore.js +68 -0
  38. package/lib/public/modules/split-pair-ui.js +1 -0
  39. package/lib/public/modules/split-pane-reconciler.js +15 -0
  40. package/lib/public/modules/split-pane-renderer.js +87 -6
  41. package/lib/public/modules/split-session-boundary.js +1 -1
  42. package/lib/public/modules/split-view.js +51 -52
  43. package/lib/public/modules/sticky-notes-checklist.js +51 -6
  44. package/lib/public/modules/stt-controller.js +4 -1
  45. package/lib/public/modules/stt-recording.js +1 -1
  46. package/lib/public/modules/stt.js +7 -2
  47. package/lib/public/modules/tools.js +18 -2
  48. package/lib/sessions.js +17 -10
  49. package/package.json +4 -3
@@ -0,0 +1,214 @@
1
+ import { store } from './store.js';
2
+ import { getWs } from './ws-ref.js';
3
+ import { getCachedSessions } from './sidebar-sessions.js';
4
+ import { takeComposerDraft, restoreComposerDraft } from './input.js';
5
+ import { resetHistoryReplayBatch } from './history-replay-batch.js';
6
+ import { updateContextPanel } from './app-panels.js';
7
+ import { paneTarget, samePaneBinding, samePaneTarget } from './pane-binding.js';
8
+
9
+ function enabled() {
10
+ return store.get('paneMode') && !store.get('paneActivationUnsupported') && !store.get('activeProjectMateId') && !/^\/p\/mate-/.test(location.pathname) && !new URLSearchParams(location.search).has('contextual') && window.parent !== window;
11
+ }
12
+ function block(value) {
13
+ var app = document.getElementById('app');
14
+ if (app) app.inert = value;
15
+ document.documentElement.dataset.paneReady = value ? 'false' : 'true';
16
+ }
17
+ function draftKey() { return String(store.get('myUserId')) + ':' + store.get('sessionOriginId'); }
18
+ function clearDeadline() {
19
+ if (store.get('paneActivationTimer')) clearTimeout(store.get('paneActivationTimer'));
20
+ store.set({ paneActivationTimer: null });
21
+ }
22
+ function failed(requestId) {
23
+ var pending = store.get('panePendingBinding');
24
+ if (!pending || requestId !== pending.requestId) return;
25
+ clearDeadline();
26
+ store.set({ paneReady: false, panePendingBinding: null });
27
+ block(true);
28
+ var frame = window.frameElement;
29
+ if (frame) frame.dispatchEvent(new window.parent.CustomEvent('clay-pane-activation-failed'));
30
+ }
31
+
32
+ // Claim ordinary initial pins before the legacy session-list handler runs.
33
+ // Authentication, list and group metadata can arrive in either order; the
34
+ // subscription retries when those authoritative prerequisites become ready.
35
+ export function primePaneSession() {
36
+ if (!enabled() || !window.frameElement || !window.frameElement.clayPane) return false;
37
+ var id = store.get('paneSessionId');
38
+ var session = (getCachedSessions() || []).find(function (item) { return item.id === id; });
39
+ if (!session || session.isMate || (session.runtimeMode || session.mode || 'gui') !== 'gui' || session.tuiSuspended) return false;
40
+ activatePaneSession(id);
41
+ return true;
42
+ }
43
+
44
+ export function activatePaneSession(id, force, expectedTarget) {
45
+ var state = store.snap(), ws = getWs();
46
+ if (state.authIdentityLoaded === false) return false;
47
+ if (state.isMultiUserMode && !state.myUserId) return false;
48
+ if (!enabled() || !state.connected || !ws || ws.readyState !== 1 || state.splitGroupsProjectSlug !== state.currentSlug) return false;
49
+ var target = paneTarget(state, getCachedSessions(), id);
50
+ if (!target) return false;
51
+ if (expectedTarget && !samePaneTarget(target, expectedTarget)) return false;
52
+ var sourceFrame = window.frameElement;
53
+ if (!expectedTarget && sourceFrame && Number(sourceFrame.dataset && sourceFrame.dataset.sessionId) === id && sourceFrame.dataset.sessionOriginId && sourceFrame.dataset.sessionOriginId !== target.sessionOriginId) return false;
54
+ var old = state.paneCommittedBinding, pending = state.panePendingBinding;
55
+ if (!force && !state.paneSuspended && samePaneTarget(pending || old, target)) return true;
56
+ if (!pending && state.sessionOriginId) {
57
+ var draft;
58
+ try { draft = takeComposerDraft(); } catch (error) { return false; }
59
+ var drafts = Object.assign({}, state.paneRichDrafts || {});
60
+ drafts[draftKey()] = draft;
61
+ store.set({ paneRichDrafts: drafts });
62
+ }
63
+ clearDeadline();
64
+ resetHistoryReplayBatch();
65
+ var generation = (store.get('paneActivationGeneration') || 0) + 1;
66
+ target = Object.assign({}, target, { generation: generation, requestId: 'pane-' + generation + '-' + Math.random().toString(36).slice(2, 12) });
67
+ var path = new URL(store.get('wsPath'), location.origin);
68
+ path.searchParams.set('session', String(id));
69
+ store.set({ paneActivationGeneration: generation, panePendingBinding: target, paneReady: false, paneSuspended: false,
70
+ paneSessionId: id, panePinPending: false, wsPath: path.pathname + path.search });
71
+ block(true);
72
+ store.set({ paneActivationTimer: setTimeout(function () { failed(target.requestId); }, 12000) });
73
+ ws.send(JSON.stringify({ type: 'switch_session', id: id, paneActivation: target }));
74
+ return true;
75
+ }
76
+
77
+ // Runs before replay batching, so an obsolete history_meta cannot open a batch
78
+ // that later consumes destination messages.
79
+ export function filterPaneActivation(message) {
80
+ if (!enabled()) return false;
81
+ var pending = store.get('panePendingBinding'), committed = store.get('paneCommittedBinding');
82
+ if (message.type === 'pane_activation_rejected') { failed(message.requestId); return true; }
83
+ if (!pending && !committed) return !!message.paneActivation;
84
+ if (['session_list', 'split_groups', 'session_presence', 'pong', 'account_auth_required', 'project_removed', 'project_access_revoked'].indexOf(message.type) >= 0 && !message.paneActivation) return false;
85
+ var expected = pending || committed;
86
+ if (!samePaneBinding(expected, message.paneActivation)) return true;
87
+ if (message.type === 'pane_activation_started') return true;
88
+ if (message.type === 'pane_activation_snapshot_done') {
89
+ if (!pending) return true;
90
+ var draft = (store.get('paneRichDrafts') || {})[String(store.get('myUserId')) + ':' + expected.sessionOriginId];
91
+ restoreComposerDraft(draft || { text: '', start: 0, end: 0 });
92
+ function paint() {
93
+ if (store.get('panePendingBinding') !== expected || !store.get('connected') || store.get('paneSuspended')) return;
94
+ var current = paneTarget(store.snap(), getCachedSessions(), expected.sessionId);
95
+ var messages = document.getElementById('messages');
96
+ if (!samePaneTarget(current, expected) ||
97
+ store.get('activeSessionId') !== expected.sessionId || store.get('sessionOriginId') !== expected.sessionOriginId || store.get('replayingHistory') ||
98
+ !messages || messages.dataset.controlSessionId !== String(expected.sessionId) || messages.dataset.controlSessionOrigin !== expected.sessionOriginId) { failed(expected.requestId); return; }
99
+ clearDeadline();
100
+ store.set({ paneCommittedBinding: expected, panePendingBinding: null, paneReady: true });
101
+ block(false);
102
+ var frame = window.frameElement;
103
+ if (frame) frame.dispatchEvent(new window.parent.CustomEvent('clay-pane-ready'));
104
+ updateContextPanel();
105
+ }
106
+ requestAnimationFrame(function () { requestAnimationFrame(paint); });
107
+ return true;
108
+ }
109
+ return false;
110
+ }
111
+
112
+ export function paneOutgoing(data) {
113
+ var message;
114
+ try { message = JSON.parse(data); } catch (error) { return null; }
115
+ if (['ping', 'pong', 'clay_ui_ready'].indexOf(message.type) >= 0) return data;
116
+ if (store.get('deferredSessionSwitch') && store.get('deferredSessionSwitch').draining) return null;
117
+ if (message.type === 'switch_session') {
118
+ var entry = store.get('pendingSessionEntryNavigation');
119
+ if (entry && entry.projectSlug === store.get('currentSlug') && String(entry.sessionId) !== String(message.id)) store.set({ pendingSessionEntryNavigation: null });
120
+ if (enabled() && (store.get('panePendingBinding') || store.get('paneCommittedBinding')) && !message.paneActivation) return null;
121
+ return data;
122
+ }
123
+ if (store.get('deferredSessionSwitch')) {
124
+ var source = store.get('deferredSessionSwitch');
125
+ if (source.draining) return null;
126
+ if (message.type === 'stop') return JSON.stringify(Object.assign({}, message, { sessionId: source.sessionId, sessionOriginId: source.origin }));
127
+ return null;
128
+ }
129
+ if (!enabled()) return data;
130
+ var binding = store.get('paneCommittedBinding');
131
+ if (store.get('panePendingBinding') || store.get('paneSuspended') || binding && !store.get('paneReady')) return null;
132
+ if (!binding) return ['stop', 'user_message', 'permission_response', 'ask_user_response', 'elicitation_response', 'user_dialog_response', 'input_sync'].indexOf(message.type) >= 0 ? null : data;
133
+ if (message.paneBinding && !samePaneBinding(binding, message.paneBinding)) return null;
134
+ var gesture = (store.get('paneActionEvents') || []).find(function (entry) { return entry.event.isTrusted && entry.event.eventPhase && samePaneBinding(entry.binding, binding); });
135
+ var action = store.get('paneCallbackBinding') || gesture && gesture.binding;
136
+ var read = /^(get_|list_)|(_get|_list|_options)$/.test(message.type) || message.type === 'load_more_history';
137
+ if (!message.paneBinding && !read && !samePaneBinding(action, binding)) return null;
138
+ return JSON.stringify(Object.assign({}, message, { paneBinding: message.paneBinding || action || binding }));
139
+ }
140
+
141
+ export function refreshPaneActivation() {
142
+ if (!enabled()) return;
143
+ var binding = store.get('panePendingBinding') || store.get('paneCommittedBinding');
144
+ if (!binding) return;
145
+ var current = paneTarget(store.snap(), getCachedSessions(), binding.sessionId);
146
+ if (samePaneTarget(current, binding)) return;
147
+ if (store.get('panePendingBinding')) failed(binding.requestId);
148
+ else { store.set({ paneReady: false }); block(true); }
149
+ }
150
+
151
+ export function initPaneActivation() {
152
+ if (!enabled() || !window.frameElement) return;
153
+ var frame = window.frameElement;
154
+ var latestId = Number(frame.dataset && (frame.dataset.requestedSessionId || frame.dataset.sessionId));
155
+ if (Number.isSafeInteger(latestId) && latestId > 0 && frame.dataset.projectSlug === store.get('currentSlug')) {
156
+ var latestPath = new URL(store.get('wsPath'), location.origin);
157
+ latestPath.searchParams.set('session', String(latestId));
158
+ store.set({ paneSessionId: latestId, panePinPending: true, wsPath: latestPath.pathname + latestPath.search });
159
+ }
160
+ block(true);
161
+ frame.clayPane = {
162
+ source: window, document: document,
163
+ identity: function () {
164
+ var binding = store.get('paneCommittedBinding');
165
+ return binding && { ownerId: binding.viewerId, projectSlug: binding.projectSlug, sessionId: binding.sessionId,
166
+ binding: binding, ready: store.get('connected') && store.get('paneReady') && !store.get('paneSuspended') };
167
+ },
168
+ activate: activatePaneSession,
169
+ checkDraft: function () { if (!store.get('panePendingBinding')) { var draft = takeComposerDraft(); restoreComposerDraft(draft); } },
170
+ suspend: function () { store.set({ paneSuspended: true, paneReady: false }); block(true); },
171
+ };
172
+ // A pointer that began on the previous conversation cannot become a Stop or
173
+ // approval click on the replacement conversation after hydration.
174
+ document.addEventListener('pointerdown', function () { store.set({ panePointerBinding: store.get('paneCommittedBinding') }); }, true);
175
+ document.addEventListener('click', function (event) {
176
+ if (store.get('paneSuspended') || !store.get('paneReady') && store.get('panePendingBinding') || event.detail && store.get('panePointerBinding') !== store.get('paneCommittedBinding')) {
177
+ event.preventDefault(); event.stopImmediatePropagation();
178
+ }
179
+ }, true);
180
+ ['click', 'input', 'change', 'keydown', 'keyup', 'submit', 'blur'].forEach(function (name) {
181
+ document.addEventListener(name, function (event) {
182
+ var keys = store.get('paneHeldKeys') || {};
183
+ var binding = store.get('paneCommittedBinding');
184
+ var previousKey = keys[event.code || event.key];
185
+ var staleKey = (name === 'keyup' || name === 'keydown' && event.repeat) && previousKey && !samePaneBinding(previousKey, binding);
186
+ if (name === 'keydown' && !event.repeat) keys[event.code || event.key] = binding;
187
+ if (name === 'keyup') delete keys[event.code || event.key];
188
+ if (staleKey || store.get('panePendingBinding') || store.get('paneSuspended') || !store.get('paneReady')) {
189
+ event.preventDefault(); event.stopImmediatePropagation(); return;
190
+ }
191
+ var events = (store.get('paneActionEvents') || []).filter(function (entry) { return entry.event.eventPhase; });
192
+ events.push({ event: event, binding: binding });
193
+ store.set({ paneHeldKeys: keys, paneActionEvents: events });
194
+ }, true);
195
+ });
196
+ store.subscribe(function (state, previous) {
197
+ if (state.activeSessionMode === 'tui' && previous.activeSessionMode !== 'tui') {
198
+ delete frame.clayPane; store.set({ paneActivationUnsupported: true }); block(false); return;
199
+ }
200
+ if (!state.connected && previous.connected) {
201
+ clearDeadline();
202
+ store.set({ paneReady: false, panePendingBinding: null, paneCommittedBinding: null }); block(true);
203
+ }
204
+ if (state.connected && (state.splitGroups !== previous.splitGroups || state.splitGroupsProjectSlug !== previous.splitGroupsProjectSlug || state.paneBindingSessions !== previous.paneBindingSessions || state.authIdentityLoaded !== previous.authIdentityLoaded || state.myUserId !== previous.myUserId || state.activeSessionId !== previous.activeSessionId || state.connected !== previous.connected)) {
205
+ queueMicrotask(function () {
206
+ refreshPaneActivation();
207
+ if (!store.get('panePendingBinding') && !store.get('paneSuspended')) {
208
+ if (store.get('panePinPending')) primePaneSession();
209
+ else if (store.get('activeSessionId') === store.get('paneSessionId')) activatePaneSession(store.get('paneSessionId'));
210
+ }
211
+ });
212
+ }
213
+ });
214
+ }
@@ -0,0 +1,82 @@
1
+ import { store } from './store.js';
2
+ import { getWs } from './ws-ref.js';
3
+
4
+ export function captureSessionAction() {
5
+ var state = store.snap();
6
+ return { projectSlug: state.currentSlug, ownerId: state.myUserId, sessionId: state.activeSessionId,
7
+ origin: state.sessionOriginId, socket: getWs(), binding: state.paneCommittedBinding || null,
8
+ epoch: state.sessionActionEpoch || 0 };
9
+ }
10
+
11
+ export function sessionActionCurrent(action, draftCompletion) {
12
+ var state = store.snap();
13
+ return !!action && action.socket === getWs() && state.connected &&
14
+ action.projectSlug === state.currentSlug && action.ownerId === state.myUserId &&
15
+ action.sessionId === state.activeSessionId && action.origin === state.sessionOriginId &&
16
+ action.epoch === (state.sessionActionEpoch || 0) &&
17
+ (draftCompletion || !state.deferredSessionSwitch) && !state.panePendingBinding && !state.paneSuspended &&
18
+ (action.binding ? state.paneReady && samePaneBinding(action.binding, state.paneCommittedBinding) : !state.paneCommittedBinding);
19
+ }
20
+
21
+ // Capture at callback creation, never when an old callback eventually sends.
22
+ export function sessionCallback(callback, draftCompletion) {
23
+ var action = captureSessionAction();
24
+ return function () {
25
+ if (!sessionActionCurrent(action, draftCompletion)) return;
26
+ var previous = store.get('paneCallbackBinding');
27
+ store.set({ paneCallbackBinding: action.binding });
28
+ try { return callback.apply(this, arguments); }
29
+ finally { store.set({ paneCallbackBinding: previous }); }
30
+ };
31
+ }
32
+
33
+ export function samePaneBinding(a, b) {
34
+ return !!a && !!b && ['requestId', 'generation', 'projectSlug', 'viewerId', 'ownerId', 'sessionId', 'sessionOriginId', 'role', 'workerGeneration', 'groupStamp', 'parentSessionOriginId'].every(function (key) { return a[key] === b[key]; });
35
+ }
36
+
37
+ export function samePaneTarget(a, b) {
38
+ return !!a && !!b && ['projectSlug', 'viewerId', 'ownerId', 'sessionId', 'sessionOriginId', 'role', 'workerGeneration', 'groupStamp', 'parentSessionOriginId'].every(function (key) { return a[key] === b[key]; });
39
+ }
40
+
41
+ export function paneTarget(state, sessions, id) {
42
+ var session = (sessions || []).find(function (item) { return item.id === id; });
43
+ if (!session || session.isMate || !session.sessionOriginId || (session.runtimeMode || session.mode || 'gui') !== 'gui' || session.tuiSuspended) return null;
44
+ var group = (state.splitGroups || []).find(function (item) { return item.members.indexOf(id) >= 0; });
45
+ var parent = session.sessionRole === 'worker' && sessions.find(function (item) { return item.id === session.parentSessionId; });
46
+ if (session.sessionRole === 'worker' && (!parent || !session.parentAvailable || !Number.isSafeInteger(session.workerGeneration) || !parent.sessionOriginId || parent.ownerId !== session.ownerId || !group || !group.pair || group.pair.driverId !== parent.id)) return null;
47
+ return {
48
+ projectSlug: state.currentSlug, viewerId: state.myUserId || null, ownerId: session.ownerId || null,
49
+ sessionId: session.id, sessionOriginId: session.sessionOriginId,
50
+ role: session.sessionRole === 'worker' ? 'worker' : 'driver', workerGeneration: session.workerGeneration || null,
51
+ parentSessionOriginId: parent ? parent.sessionOriginId : null,
52
+ groupStamp: group ? JSON.stringify({ id: group.id, members: group.members, pair: group.pair || null }) : null,
53
+ };
54
+ }
55
+
56
+ // Ordinary reusable panes have a live document binding; legacy/canonical frames
57
+ // retain their existing URL and receipt checks in their own bridge paths.
58
+ export function livePaneBinding(frame, message) {
59
+ if (!frame || !frame.clayPane) return false;
60
+ var api = frame.clayPane, live = api.identity();
61
+ var state = store.snap(), split = state.splitPanes;
62
+ var target = live && paneTarget(state, state.paneBindingSessions || [], live.sessionId);
63
+ return api.source === frame.contentWindow && api.document === frame.contentDocument && live && live.ready &&
64
+ samePaneTarget(target, live.binding) &&
65
+ live.ownerId === (state.myUserId || null) && live.projectSlug === state.currentSlug &&
66
+ split && split.panes.some(function (pane) { return pane.slug === live.projectSlug && pane.sessionId === live.sessionId; }) &&
67
+ (!message || samePaneBinding(live.binding, message.paneBinding));
68
+ }
69
+
70
+ export function paneMessageBinding() {
71
+ return store.get('paneReady') && !store.get('paneSuspended') ? store.get('paneCommittedBinding') : null;
72
+ }
73
+
74
+ export function currentPaneReview(container) {
75
+ if (!container || !container.paneBinding) return true;
76
+ return container.isConnected !== false && store.get('paneReady') && !store.get('paneSuspended') && samePaneBinding(container.paneBinding, store.get('paneCommittedBinding'));
77
+ }
78
+
79
+ export function paneCallbackCurrent(binding) {
80
+ var current = store.get('panePendingBinding') || store.get('paneCommittedBinding');
81
+ return current ? samePaneBinding(current, binding) : !binding;
82
+ }
@@ -5,6 +5,7 @@ import { forwardStudioPresentation } from './clay-studio-render.js';
5
5
  // Same-origin messages only, both directions.
6
6
 
7
7
  import { store } from './store.js';
8
+ import { paneMessageBinding, samePaneBinding } from './pane-binding.js';
8
9
  import { getEffectiveContextView } from './context-view-preference.js';
9
10
  import { forceExternalLinkToNewTab } from './pane-links.js';
10
11
 
@@ -12,6 +13,7 @@ export function reportPaneContext(data) {
12
13
  if (!store.get('paneMode') || window.parent === window) return;
13
14
  window.parent.postMessage({
14
15
  type: "clay-pane-context",
16
+ paneBinding: paneMessageBinding(),
15
17
  sessionId: store.get('activeSessionId'),
16
18
  pct: data.pct,
17
19
  used: data.used,
@@ -27,7 +29,7 @@ export function reportPaneContext(data) {
27
29
  // Hand the event to the parent shell, which owns the single flow.
28
30
  export function forwardPaneAuthRequired(message) {
29
31
  if (!store.get('paneMode') || window.parent === window) return false;
30
- window.parent.postMessage({ type: "clay-pane-auth-required", message: message }, window.location.origin);
32
+ window.parent.postMessage({ type: "clay-pane-auth-required", paneBinding: paneMessageBinding(), message: message }, window.location.origin);
31
33
  return true;
32
34
  }
33
35
 
@@ -38,7 +40,7 @@ export function forwardPaneMarkdownPresentation(message) {
38
40
 
39
41
  export function forwardPaneIssueReference(ref, projectSlug) {
40
42
  if (!store.get('paneMode') || window.parent === window) return false;
41
- window.parent.postMessage({ type: "clay-pane-open-issue", ref: ref, projectSlug: projectSlug || store.get('currentSlug') }, window.location.origin);
43
+ window.parent.postMessage({ type: "clay-pane-open-issue", paneBinding: paneMessageBinding(), ref: ref, projectSlug: projectSlug || store.get('currentSlug') }, window.location.origin);
42
44
  return true;
43
45
  }
44
46
 
@@ -70,6 +72,7 @@ export function initPaneBridge() {
70
72
  if (event.origin !== window.location.origin) return;
71
73
  var msg = event.data;
72
74
  if (!msg || msg.type !== "clay-pane-toggle-context") return;
75
+ if (event.source !== window.parent || store.get('paneCommittedBinding') && !samePaneBinding(paneMessageBinding(), msg.paneBinding)) return;
73
76
  togglePaneContextPanel();
74
77
  });
75
78
  }
@@ -1,9 +1,10 @@
1
1
  import { store } from './store.js';
2
+ import { livePaneBinding, paneMessageBinding } from './pane-binding.js';
2
3
  import { showToast } from './utils.js';
3
4
 
4
5
  export function forwardPaneBrowserLink(url) {
5
6
  if (!store.get('paneMode') || window.parent === window) return false;
6
- window.parent.postMessage({ type: 'clay-pane-open-browser', url: url, projectSlug: store.get('currentSlug'), sessionId: String(store.get('activeSessionId')) }, window.location.origin);
7
+ window.parent.postMessage({ type: 'clay-pane-open-browser', paneBinding: paneMessageBinding(), url: url, projectSlug: store.get('currentSlug'), sessionId: String(store.get('activeSessionId')) }, window.location.origin);
7
8
  return true;
8
9
  }
9
10
  export function handlePaneBrowserMessage(event, host) {
@@ -13,8 +14,9 @@ export function handlePaneBrowserMessage(event, host) {
13
14
  var pane = frame && s.splitPanes && s.splitPanes.panes && s.splitPanes.panes.find(function (item) { return String(item.slug) === frame.dataset.projectSlug && String(item.sessionId) === frame.dataset.sessionId; });
14
15
  if (!pane || msg.projectSlug !== s.currentSlug || msg.projectSlug !== frame.dataset.projectSlug || String(msg.sessionId) !== frame.dataset.sessionId) return true;
15
16
  var expected = new URL('/p/' + encodeURIComponent(pane.slug) + '/?pane=1&session=' + encodeURIComponent(pane.sessionId), window.location.href).href;
16
- if (frame.src !== expected) return true;
17
+ if (frame.clayPane ? !livePaneBinding(frame, msg) : frame.src !== expected) return true;
17
18
  // The authenticated host owns the browser; a live same-project pane supplies only a URL.
18
- import('./shared-browser.js').then(function (module) { module.openSharedBrowserAt(msg.url); }).catch(function (error) { showToast(error.message || 'Could not open the browser.', 'warn'); });
19
+ var binding = frame.clayPane && frame.clayPane.identity().binding;
20
+ import('./shared-browser.js').then(function (module) { if (binding && (!livePaneBinding(frame, msg) || frame.clayPane.identity().binding !== binding)) return; module.openSharedBrowserAt(msg.url); }).catch(function (error) { showToast(error.message || 'Could not open the browser.', 'warn'); });
19
21
  return true;
20
22
  }
@@ -1,4 +1,5 @@
1
1
  import { store } from './store.js';
2
+ import { livePaneBinding, paneMessageBinding } from './pane-binding.js';
2
3
 
3
4
  function paneFrame(host, source) {
4
5
  if (!host || !source) return null;
@@ -19,6 +20,7 @@ function matchingPane(frame, split) {
19
20
  }
20
21
 
21
22
  function hasLivePaneSource(frame, pane) {
23
+ if (frame && frame.clayPane) return livePaneBinding(frame);
22
24
  if (!frame || !pane || typeof frame.src !== "string") return false;
23
25
  var expected = "/p/" + encodeURIComponent(pane.slug) + "/?pane=1&session=" + encodeURIComponent(pane.sessionId);
24
26
  try { return new URL(expected, window.location.href).href === frame.src; } catch (e) { return false; }
@@ -41,6 +43,7 @@ export function forwardPaneFileReference(path, opts) {
41
43
  if (!store.get('paneMode') || window.parent === window || typeof path !== "string" || !path) return false;
42
44
  window.parent.postMessage({
43
45
  type: "clay-pane-open-file",
46
+ paneBinding: paneMessageBinding(),
44
47
  path: path,
45
48
  line: opts && opts.line != null ? opts.line : null,
46
49
  column: opts && opts.column != null ? opts.column : null,
@@ -57,6 +60,7 @@ export function handlePaneFileMessage(event, host, openFile, openFiles) {
57
60
  var frame = paneFrame(host, event.source);
58
61
  var state = store.snap();
59
62
  var pane = matchingPane(frame, state.splitPanes);
63
+ if (frame && frame.clayPane && !livePaneBinding(frame, msg)) return true;
60
64
  if (!pane || !hasLivePaneSource(frame, pane) || String(pane.slug) !== String(state.currentSlug) || msg.projectSlug !== frame.dataset.projectSlug || String(msg.sessionId) !== String(frame.dataset.sessionId) || !validLocation(msg.line) || !validLocation(msg.column)) return true;
61
65
  if (typeof msg.path !== "string" || !msg.path) return true;
62
66
  if (!state.connected || typeof openFile !== "function") return true;
@@ -1,4 +1,5 @@
1
1
  import { store } from './store.js';
2
+ import { livePaneBinding } from './pane-binding.js';
2
3
  import { openIssues } from './issues.js';
3
4
 
4
5
  function isIssueReference(ref) {
@@ -8,7 +9,7 @@ function isIssueReference(ref) {
8
9
  function isPaneSource(host, source) {
9
10
  var frames = host.querySelectorAll(".split-pane-frame");
10
11
  for (var i = 0; i < frames.length; i++) {
11
- if (frames[i].contentWindow === source) return true;
12
+ if (frames[i].contentWindow === source) return !frames[i].clayPane || livePaneBinding(frames[i]);
12
13
  }
13
14
  return false;
14
15
  }
@@ -18,6 +19,8 @@ export function handlePaneIssueMessage(event, host) {
18
19
  var msg = event.data;
19
20
  if (!msg || msg.type !== "clay-pane-open-issue") return false;
20
21
  if (!isIssueReference(msg.ref) || !isPaneSource(host, event.source)) return true;
22
+ var frame = Array.from(host.querySelectorAll('.split-pane-frame')).find(function (item) { return item.contentWindow === event.source; });
23
+ if (frame && frame.clayPane && !livePaneBinding(frame, msg)) return true;
21
24
 
22
25
  var issueTarget = store.get('issuesCache') && store.get('issuesCache')[msg.ref];
23
26
  var projectSlug = (issueTarget && issueTarget.projectSlug) || msg.projectSlug || store.get('currentSlug');
@@ -1,5 +1,7 @@
1
1
  import { escapeHtml } from './utils.js';
2
2
  import { iconHtml, refreshIcons } from './icons.js';
3
+ import { store } from './store.js';
4
+ import { captureSessionAction, sessionActionCurrent } from './pane-binding.js';
3
5
 
4
6
  var ctx;
5
7
  var rewindMode = false;
@@ -81,6 +83,7 @@ function initiateRewind(uuid) {
81
83
  return;
82
84
  }
83
85
  pendingRewindUuid = uuid;
86
+ store.set({ rewindAction: captureSessionAction() });
84
87
  rewindPreviewInFlight = true;
85
88
  if (ctx.ws && ctx.connected) {
86
89
  ctx.ws.send(JSON.stringify({ type: "rewind_preview", uuid: uuid }));
@@ -120,6 +123,7 @@ function updateSummaryForMode() {
120
123
  }
121
124
 
122
125
  export function showRewindModal(data) {
126
+ if (!sessionActionCurrent(store.get('rewindAction'))) return;
123
127
  rewindPreviewInFlight = false;
124
128
 
125
129
  // Ignore stale preview results that don't match current pending UUID
@@ -367,6 +371,7 @@ export function initRewind(_ctx) {
367
371
  });
368
372
 
369
373
  rewindConfirmBtn.addEventListener("click", function() {
374
+ if (!sessionActionCurrent(store.get('rewindAction'))) { hideRewindModal(); return; }
370
375
  if (rewindExecuting) return;
371
376
  if (pendingRewindUuid && ctx.ws && ctx.connected) {
372
377
  rewindExecuting = true;
@@ -4,6 +4,7 @@ import { addSystemMessage, VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.
4
4
  import { openPairDialog } from './split-pair-ui.js';
5
5
  import { buildAgentVendorSelect, fillAgentModels, buildAgentEffortSelect, fillAgentEffort } from './agent-config-selects.js';
6
6
  import { showToast } from './utils.js';
7
+ import { sessionCallback, captureSessionAction, sessionActionCurrent } from './pane-binding.js';
7
8
 
8
9
  var addWorkerButton = null;
9
10
  var handoffButton = null;
@@ -33,6 +34,7 @@ function handoffField(labelText, control) {
33
34
  function requestHandoffDialog() {
34
35
  var ws = getWs();
35
36
  if (!ws || ws.readyState !== 1) return;
37
+ store.set({ handoffAction: captureSessionAction() });
36
38
  ws.send(JSON.stringify({ type: "handoff_session_options" }));
37
39
  }
38
40
 
@@ -47,6 +49,7 @@ function openWorkerDialog() {
47
49
  }
48
50
 
49
51
  function showHandoffDialog(options) {
52
+ if (!sessionActionCurrent(store.get('handoffAction'))) return;
50
53
  closeHandoffDialog();
51
54
  var installed = options.installedVendors || [];
52
55
  var vendorInfo = store.get('vendorInfo') || {};
@@ -147,7 +150,7 @@ function showHandoffDialog(options) {
147
150
  if (event.key === "Escape") closeHandoffDialog();
148
151
  };
149
152
  document.addEventListener("keydown", handoffEscapeHandler);
150
- submit.addEventListener("click", function () {
153
+ submit.addEventListener("click", sessionCallback(function () {
151
154
  var ws = getWs();
152
155
  if (!ws || ws.readyState !== 1) return;
153
156
  ws.send(JSON.stringify({
@@ -158,7 +161,7 @@ function showHandoffDialog(options) {
158
161
  }));
159
162
  submit.disabled = true;
160
163
  submit.textContent = "Continuing…";
161
- });
164
+ }));
162
165
  vendorSelect.focus();
163
166
  }
164
167
 
@@ -109,6 +109,8 @@ function currentRenameCanSend(state) {
109
109
  String(store.get('activeSessionId')) === String(state.sessionId) :
110
110
  state && state.row && String(state.row.dataset.sessionId) === String(state.sessionId);
111
111
  return !!(state && state.row && state.row.isConnected && sameSession &&
112
+ (state.source !== 'header' || state.origin === store.get('sessionOriginId')) &&
113
+ state.epoch === (store.get('sessionActionEpoch') || 0) && !store.get('panePendingBinding') && !store.get('deferredSessionSwitch') &&
112
114
  store.get('connected') && state.ws === ws && ws && ws.readyState === 1 &&
113
115
  state.slug === store.get('currentSlug') &&
114
116
  (!permissions || permissions.sessionRename !== false));
@@ -158,6 +160,8 @@ function beginSessionInlineRename(row, title, control, session, source, inputCla
158
160
  input: input,
159
161
  originalNodes: originalNodes,
160
162
  sessionId: session.id,
163
+ origin: session.sessionOriginId,
164
+ epoch: store.get('sessionActionEpoch') || 0,
161
165
  currentTitle: session.title || "New Session",
162
166
  control: control || null,
163
167
  controlDisplay: controlDisplay,
@@ -202,6 +206,7 @@ export function startHeaderSessionInlineRename(title, control, session) {
202
206
 
203
207
  store.subscribe(function (state, previous) {
204
208
  if (state.currentSlug !== previous.currentSlug || state.dmMode !== previous.dmMode ||
209
+ state.sessionActionEpoch !== previous.sessionActionEpoch || state.panePendingBinding !== previous.panePendingBinding ||
205
210
  (previous.connected && !state.connected)) {
206
211
  closeSessionContextMenu(false);
207
212
  cancelSessionInlineRename(false);
@@ -0,0 +1,21 @@
1
+ import { store } from './store.js';
2
+ import { takeComposerDraft, restoreComposerDraft } from './input.js';
3
+
4
+ function key() { return [store.get('myUserId') || '', store.get('currentSlug') || '', store.get('sessionOriginId') || ''].join(':'); }
5
+ export function checkComposerDeparture() {
6
+ var draft = takeComposerDraft();
7
+ restoreComposerDraft(draft);
8
+ }
9
+ export function saveSessionRichDraft() {
10
+ if (store.get('activeProjectMateId') || !store.get('sessionOriginId') || store.get('panePendingBinding')) return;
11
+ var drafts = Object.assign({}, store.get('sessionRichDrafts') || {});
12
+ var draft = takeComposerDraft();
13
+ drafts[key()] = draft;
14
+ restoreComposerDraft(draft);
15
+ store.set({ sessionRichDrafts: drafts });
16
+ }
17
+ export function restoreSessionRichDraft() {
18
+ if (store.get('activeProjectMateId') || store.get('panePendingBinding')) return;
19
+ var draft = (store.get('sessionRichDrafts') || {})[key()];
20
+ restoreComposerDraft(draft || { text: (store.get('sessionDrafts') || {})[store.get('activeSessionId')] || '', start: 0, end: 0 });
21
+ }
@@ -0,0 +1,67 @@
1
+ import { store } from './store.js';
2
+ import { getWs } from './ws-ref.js';
3
+ import { composerDepartureReason } from './input.js';
4
+ import { processMessage } from './app-message-router.js';
5
+
6
+ function notice(show, message) {
7
+ var element = document.getElementById('session-switch-wait');
8
+ if (!show) { if (element) element.remove(); return; }
9
+ if (element) { if (message) element.textContent = message; return; }
10
+ var area = document.getElementById('input-area');
11
+ if (!area) return;
12
+ element = document.createElement('div'); element.id = 'session-switch-wait';
13
+ element.className = 'session-switch-wait'; element.setAttribute('role', 'status');
14
+ element.textContent = 'Session change waiting. Finish or discard voice input, or wait for attachments. Your draft stays with this conversation.';
15
+ area.prepend(element);
16
+ }
17
+
18
+ // Keep the source composer and its async completions together. The destination
19
+ // replay waits in order; it cannot change identity midway through an upload.
20
+ export function deferSessionSwitch(message) {
21
+ var pending = store.get('deferredSessionSwitch');
22
+ if (pending && pending.draining) return false;
23
+ if (['account_auth_required', 'project_removed', 'project_access_revoked'].indexOf(message.type) >= 0) {
24
+ store.set({ deferredSessionSwitch: null }); notice(false); return false;
25
+ }
26
+ if (!pending && message.type === 'session_switched' && composerDepartureReason()) {
27
+ pending = { socket: getWs(), projectSlug: store.get('currentSlug'), ownerId: store.get('myUserId'),
28
+ sessionId: store.get('activeSessionId'), origin: store.get('sessionOriginId'), processing: store.get('processing'), messages: [] };
29
+ var speech = store.get('speechActive');
30
+ if (speech) speech.autoSend = false;
31
+ store.set({ deferredSessionSwitch: pending }); notice(true);
32
+ }
33
+ if (!pending) return false;
34
+ if (pending.projectSlug === store.get('currentSlug') && pending.ownerId === store.get('myUserId')) pending.socket = getWs();
35
+ if (store.get('connected') && pending.processing && !store.get('processing')) store.set({ processing: true });
36
+ pending.messages.push(message);
37
+ if (message.type === 'session_list' || message.type === 'session_switched') queueMicrotask(drain);
38
+ return true;
39
+ }
40
+
41
+ function drain() {
42
+ var pending = store.get('deferredSessionSwitch');
43
+ if (!pending || pending.draining || composerDepartureReason() || !store.get('connected')) return;
44
+ if (pending.socket !== getWs() || pending.projectSlug !== store.get('currentSlug') || pending.ownerId !== store.get('myUserId')) return;
45
+ var latestSwitch, latestList;
46
+ pending.messages.forEach(function (message) {
47
+ if (message.type === 'session_switched') latestSwitch = message;
48
+ if (message.type === 'session_list') latestList = message;
49
+ });
50
+ if (!latestSwitch || !latestList) return;
51
+ var target = (latestList.sessions || []).find(function (session) { return session.id === latestSwitch.id; });
52
+ if (!target || target.sessionOriginId !== latestSwitch.sessionOriginId) {
53
+ notice(true, 'The conversation changed while your draft was waiting. Your draft is preserved. Choose a conversation to continue.');
54
+ return;
55
+ }
56
+ // Restoring superseded snapshots must not trigger outbound session effects
57
+ // before the final authoritative target is installed.
58
+ pending.draining = true;
59
+ pending.finalSwitch = latestSwitch;
60
+ pending.messages.forEach(processMessage);
61
+ store.set({ deferredSessionSwitch: null }); notice(false);
62
+ }
63
+
64
+ store.subscribe(function (state, previous) {
65
+ if (state.deferredSessionSwitch && (state.composerUploadingCount !== previous.composerUploadingCount ||
66
+ state.speechActive !== previous.speechActive || state.speechRetry !== previous.speechRetry || state.connected !== previous.connected)) queueMicrotask(drain);
67
+ });