clay-server 5.2.0-beta.8 → 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 (48) 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/tools.js +18 -2
  47. package/lib/sessions.js +15 -8
  48. package/package.json +4 -3
@@ -0,0 +1,20 @@
1
+ // Pending requests can precede the initial history window. Restate their exact
2
+ // display payloads before snapshot completion, without resolving or replaying
3
+ // any human decision. Client renderers deduplicate by their original IDs.
4
+ function send(session, emit) {
5
+ Object.keys(session.pendingAskUser || {}).forEach(function (id) {
6
+ var pending = session.pendingAskUser[id];
7
+ var input = pending.input || { questions: pending.request && pending.request.questions || [] };
8
+ emit({ type: 'ask_user_pending', toolId: id, input: input });
9
+ });
10
+ Object.keys(session.pendingElicitations || {}).forEach(function (id) {
11
+ var request = session.pendingElicitations[id].request || {};
12
+ emit({ type: 'elicitation_request', requestId: id, serverName: request.serverName, message: request.message,
13
+ mode: request.mode || 'form', url: request.url || null, elicitationId: request.elicitationId || null, requestedSchema: request.requestedSchema || null });
14
+ });
15
+ Object.keys(session.pendingUserDialogs || {}).forEach(function (id) {
16
+ var request = session.pendingUserDialogs[id].request || {};
17
+ emit({ type: 'user_dialog_request', requestId: id, dialogKind: request.dialogKind, payload: request.payload || {}, toolUseId: request.toolUseID || null });
18
+ });
19
+ }
20
+ module.exports = { send: send };
@@ -0,0 +1,114 @@
1
+ var provenance = require('./session-provenance');
2
+
3
+ // This opt-in protocol only describes an authenticated ordinary pane snapshot.
4
+ // It grants no action authority and is never a visible-render acknowledgement.
5
+ function begin(ctx, ws, msg) {
6
+ if (!Object.prototype.hasOwnProperty.call(msg, 'paneActivation')) {
7
+ ws._clayPaneActivation = null;
8
+ return null;
9
+ }
10
+ var request = msg.paneActivation;
11
+ var session = ctx.sm.sessions.get(msg.id);
12
+ var userId = ws._clayUser && ws._clayUser.id || null;
13
+ var worker = provenance.isWorker(session);
14
+ var parentSession = worker && Array.from(ctx.sm.sessions.values()).find(function (parent) { return parent.sessionOriginId === session.sessionProvenance.parentSessionOriginId && !provenance.isWorker(parent); });
15
+ var identity = session && {
16
+ requestId: request && request.requestId,
17
+ generation: request && request.generation,
18
+ projectSlug: ctx.slug,
19
+ viewerId: userId,
20
+ ownerId: session.ownerId || null,
21
+ sessionId: session.localId,
22
+ sessionOriginId: session.sessionOriginId,
23
+ role: worker ? 'worker' : 'driver',
24
+ workerGeneration: worker ? session.sessionProvenance.generation : null,
25
+ parentSessionOriginId: worker ? session.sessionProvenance.parentSessionOriginId : null,
26
+ groupStamp: groupStamp(),
27
+ };
28
+ function groupStamp() {
29
+ var group = ctx.splitStore && ctx.splitStore.groupForMember(msg.id);
30
+ return group ? JSON.stringify({ id: group.id, members: group.members, pair: group.pair || null }) : null;
31
+ }
32
+ function allowed() {
33
+ if (typeof ws._clayAuthCheck === 'function' && !ws._clayAuthCheck()) return false;
34
+ var access = ctx.getProjectAccess ? ctx.getProjectAccess() : { visibility: 'public' };
35
+ if (!ctx.usersModule.isMultiUser()) return !session.ownerId;
36
+ return !!userId &&
37
+ !!ctx.usersModule.findUserById(userId) &&
38
+ ctx.usersModule.canAccessProject(userId, access) &&
39
+ ctx.usersModule.canAccessSession(userId, session, access);
40
+ }
41
+ var valid = request && typeof request === 'object' &&
42
+ typeof request.requestId === 'string' && /^[a-zA-Z0-9_-]{1,96}$/.test(request.requestId) &&
43
+ Number.isSafeInteger(request.generation) && request.generation > (ws._clayPaneActivationGeneration || 0) &&
44
+ ws._clayPane === true && !ws._clayContextual && !ctx.isMate && ws.readyState === 1 &&
45
+ session && session.sessionOriginId && !session.isMate &&
46
+ (session.runtimeMode || session.mode || 'gui') === 'gui' && !session.tuiSuspended &&
47
+ request.projectSlug === identity.projectSlug && request.viewerId === identity.viewerId &&
48
+ request.ownerId === identity.ownerId && request.sessionOriginId === identity.sessionOriginId &&
49
+ request.role === identity.role && request.workerGeneration === identity.workerGeneration &&
50
+ (!ctx.splitStore || request.groupStamp === identity.groupStamp && request.parentSessionOriginId === identity.parentSessionOriginId) &&
51
+ (!worker || Number.isSafeInteger(identity.workerGeneration) && identity.workerGeneration > 0) && allowed();
52
+ if (!valid) {
53
+ // Do not disclose whether a foreign or removed session exists.
54
+ ctx.sendTo(ws, { type: 'pane_activation_rejected', requestId: request && typeof request.requestId === 'string' ? request.requestId.slice(0, 96) : null });
55
+ return false;
56
+ }
57
+ ws._clayPaneActivationGeneration = identity.generation;
58
+ ws._clayPaneActivation = identity;
59
+ function current() {
60
+ return ws.readyState === 1 && ws._clayPane === true && !ws._clayContextual && !ctx.isMate && !session.isMate &&
61
+ ws._clayPaneActivation === identity &&
62
+ ctx.sm.sessions.get(msg.id) === session && session.sessionOriginId === identity.sessionOriginId &&
63
+ (ws._clayUser && ws._clayUser.id || null) === userId &&
64
+ (session.ownerId || null) === identity.ownerId &&
65
+ (session.runtimeMode || session.mode || 'gui') === 'gui' && !session.tuiSuspended &&
66
+ (provenance.isWorker(session) ? 'worker' : 'driver') === identity.role &&
67
+ (provenance.isWorker(session) ? session.sessionProvenance.generation : null) === identity.workerGeneration &&
68
+ groupStamp() === identity.groupStamp &&
69
+ (!worker || parentSession && ctx.sm.sessions.get(parentSession.localId) === parentSession &&
70
+ session.sessionProvenance.parentSessionOriginId === identity.parentSessionOriginId && parentSession.sessionOriginId === identity.parentSessionOriginId &&
71
+ !provenance.isWorker(parentSession) && (parentSession.ownerId || null) === identity.ownerId) &&
72
+ allowed();
73
+ }
74
+ function send(message) {
75
+ if (current() && ws._clayActiveSession === session.localId) ctx.sendTo(ws, Object.assign({}, message, { paneActivation: identity }));
76
+ }
77
+ var historyItems = [], historyText = 0;
78
+ // Only synchronous replay uses this bounded envelope. Each actual disclosure
79
+ // rechecks live authorization; nothing is cached across a send or async work.
80
+ send.history = function (message) {
81
+ if (message.type === 'history_done') {
82
+ flushHistory();
83
+ send(message);
84
+ return;
85
+ }
86
+ historyItems.push(message);
87
+ historyText += typeof message.text === 'string' ? message.text.length : 0;
88
+ if (historyItems.length >= 100 || historyText >= 65536) flushHistory();
89
+ };
90
+ function flushHistory() {
91
+ if (!historyItems.length) return;
92
+ var items = historyItems;
93
+ historyItems = []; historyText = 0;
94
+ send({ type: 'pane_history_batch', items: items });
95
+ }
96
+ if (current()) ctx.sendTo(ws, { type: 'pane_activation_started', paneActivation: identity });
97
+ ws._clayPaneActivationValid = function () { return current() && ws._clayActiveSession === identity.sessionId; };
98
+ return {
99
+ validate: function () {
100
+ if (current()) return true;
101
+ ctx.sendTo(ws, { type: 'pane_activation_rejected', requestId: identity.requestId });
102
+ ws._clayPaneActivation = null;
103
+ return false;
104
+ },
105
+ send: send,
106
+ finish: function () {
107
+ // Includes history listeners, status, permissions and context sources.
108
+ // Consumers still need to observe their own DOM/composer readiness.
109
+ if (ws._clayActiveSession === session.localId) send({ type: 'pane_activation_snapshot_done' });
110
+ },
111
+ };
112
+ }
113
+
114
+ module.exports = { begin: begin };
@@ -0,0 +1,26 @@
1
+ var AsyncLocalStorage = require('node:async_hooks').AsyncLocalStorage;
2
+ var requests = new AsyncLocalStorage();
3
+
4
+ function same(a, b) {
5
+ return !!a && !!b && ['requestId', 'generation', 'projectSlug', 'viewerId', 'ownerId', 'sessionId', 'sessionOriginId', 'role', 'workerGeneration', 'groupStamp', 'parentSessionOriginId'].every(function (key) { return a[key] === b[key]; });
6
+ }
7
+ function run(ws, msg, dispatch) {
8
+ var binding = ws._clayPaneActivation;
9
+ if (!binding && msg.paneBinding || binding && msg.type === 'switch_session' && !msg.paneActivation) return;
10
+ if (binding && ['switch_session', 'ping', 'pong', 'clay_ui_ready'].indexOf(msg.type) < 0) {
11
+ if (!same(binding, msg.paneBinding) || !ws._clayPaneActivationValid || !ws._clayPaneActivationValid()) return;
12
+ }
13
+ return requests.run({ ws: ws, binding: binding || null }, function () { return dispatch(ws, msg); });
14
+ }
15
+ // A direct asynchronous reply retains the request's original binding. Live
16
+ // provider events instead supply their authoritative source session explicitly.
17
+ function outbound(ws, message, sessionId) {
18
+ if (!message || message.paneActivation || /^pane_activation_/.test(message.type)) return message;
19
+ var scope = requests.getStore();
20
+ var binding = sessionId === undefined ? scope && scope.ws === ws && scope.binding : ws._clayPaneActivation;
21
+ if (!binding) return message;
22
+ if (sessionId !== undefined && binding.sessionId !== sessionId) return null;
23
+ if (!same(binding, ws._clayPaneActivation) || !ws._clayPaneActivationValid || !ws._clayPaneActivationValid()) return null;
24
+ return Object.assign({}, message, { paneActivation: binding });
25
+ }
26
+ module.exports = { run: run, outbound: outbound, same: same };
@@ -804,6 +804,8 @@ function attachSessions(ctx) {
804
804
  // adoptOrphanedCliSessions). Click a session in the sidebar instead.
805
805
 
806
806
  if (msg.type === "switch_session") {
807
+ var paneActivation = require('./pane-session-activation').begin(ctx, ws, msg);
808
+ if (paneActivation === false) return true;
807
809
  if (msg.id && sm.sessions.has(msg.id)) {
808
810
  // resolveSessionForView sets runtimeMode / runtimeTerminalId /
809
811
  // tuiSuspended (and hydrates the transcript) before sm.switchSession
@@ -817,6 +819,7 @@ function attachSessions(ctx) {
817
819
  resolveSessionForView(xmTarget, ws);
818
820
  }
819
821
  var switchTargetSess = sm.sessions.get(msg.id);
822
+ if (paneActivation && !paneActivation.validate()) return true;
820
823
  var switchTargetVendor = switchTargetSess && (switchTargetSess.vendor || sm.defaultVendor || "claude");
821
824
  if (switchTargetSess && !switchTargetSess.effort) {
822
825
  switchTargetSess.effort = yoke.clampEffort(
@@ -829,20 +832,20 @@ function attachSessions(ctx) {
829
832
  if (usersModule.isMultiUser() && ws._clayUser) {
830
833
  var switchTarget = sm.sessions.get(msg.id);
831
834
  if (!usersModule.canAccessSession(ws._clayUser.id, switchTarget, { visibility: "public" })) return true;
832
- ws._clayActiveSession = msg.id;
833
- sm.switchSession(msg.id, ws, hydrateImageRefs);
835
+ sm.switchSession(msg.id, ws, hydrateImageRefs, paneActivation && paneActivation.send, paneActivation && paneActivation.validate);
834
836
  if (!ws._clayPane) broadcastPresence();
835
837
  } else {
836
- ws._clayActiveSession = msg.id;
837
- sm.switchSession(msg.id, ws, hydrateImageRefs);
838
+ sm.switchSession(msg.id, ws, hydrateImageRefs, paneActivation && paneActivation.send, paneActivation && paneActivation.validate);
838
839
  }
839
840
  // Send per-session context sources
840
841
  if (typeof loadContextSources === "function") {
841
842
  var switchedSources = loadContextSources(slug, msg.id);
842
- sendTo(ws, { type: "context_sources_state", active: switchedSources });
843
+ if (paneActivation) paneActivation.send({ type: "context_sources_state", active: switchedSources });
844
+ else sendTo(ws, { type: "context_sources_state", active: switchedSources });
843
845
  }
844
846
  var swPresKey = ws._clayUser ? ws._clayUser.id : "_default";
845
847
  if (!ws._clayPane) userPresence.setPresence(slug, swPresKey, userPresence.sessionIdForPersistence(switchTargetSess), null);
848
+ if (paneActivation) paneActivation.finish();
846
849
  }
847
850
  return true;
848
851
  }
@@ -1084,6 +1087,12 @@ function attachSessions(ctx) {
1084
1087
 
1085
1088
  if (msg.type === "stop") {
1086
1089
  var session = getSessionForWs(ws);
1090
+ if (msg.sessionId != null || msg.sessionOriginId != null) {
1091
+ session = sm.sessions.get(msg.sessionId);
1092
+ if (!session || session.sessionOriginId !== msg.sessionOriginId ||
1093
+ !usersModule.isMultiUser() && session.ownerId ||
1094
+ usersModule.isMultiUser() && (!ws._clayUser || !usersModule.canAccessSession(ws._clayUser.id, session, getProjectAccess()))) return true;
1095
+ }
1087
1096
  stopSession(session, ws && ws._clayUser);
1088
1097
  return true;
1089
1098
  }
@@ -1465,6 +1474,7 @@ function attachSessions(ctx) {
1465
1474
  var sessionId = sm.permissionRequestIndex[requestId];
1466
1475
  var session = sessionId ? sm.sessions.get(sessionId) : getSessionForWs(ws);
1467
1476
  if (!session) return true;
1477
+ if (ws._clayPaneActivation && session.localId !== ws._clayPaneActivation.sessionId) return true;
1468
1478
  var pending = session.pendingPermissions[requestId];
1469
1479
  if (!pending) return true;
1470
1480
  delete sm.permissionRequestIndex[requestId];
package/lib/project.js CHANGED
@@ -459,7 +459,8 @@ function createProjectContext(opts) {
459
459
  if (obj.type === "loop_registry_updated") payload.records = getHubSchedules(userId);
460
460
  else payload.schedules = getHubSchedules(userId);
461
461
  }
462
- ws.send(JSON.stringify(payload));
462
+ payload = require('./pane-session-transport').outbound(ws, payload);
463
+ if (payload) ws.send(JSON.stringify(payload));
463
464
  }
464
465
 
465
466
  function sendToAdmins(obj) {
@@ -555,7 +556,8 @@ function createProjectContext(opts) {
555
556
  var data = JSON.stringify(obj), delivered = 0;
556
557
  for (var ws of clients) {
557
558
  if (ws.readyState === 1 && canReceiveMate(ws) && canReceiveYou(ws, sessionId) && ws._clayActiveSession === sessionId) {
558
- ws.send(data); delivered++; if (onSent) onSent(ws);
559
+ var panePayload = require('./pane-session-transport').outbound(ws, obj, sessionId);
560
+ if (panePayload) { ws.send(JSON.stringify(panePayload)); delivered++; if (onSent) onSent(ws); }
559
561
  }
560
562
  }
561
563
  homeDebateClients.forEach(function (ws) {
@@ -569,7 +571,8 @@ function createProjectContext(opts) {
569
571
  var data = JSON.stringify(obj);
570
572
  for (var ws of clients) {
571
573
  if (ws !== sender && ws.readyState === 1 && canReceiveMate(ws) && canReceiveYou(ws, sessionId) && ws._clayActiveSession === sessionId) {
572
- ws.send(data);
574
+ var panePayload = require('./pane-session-transport').outbound(ws, obj, sessionId);
575
+ if (panePayload) ws.send(JSON.stringify(panePayload));
573
576
  }
574
577
  }
575
578
  }
@@ -1827,6 +1830,10 @@ function createProjectContext(opts) {
1827
1830
  }
1828
1831
 
1829
1832
  function handleMessage(ws, msg) {
1833
+ return require('./pane-session-transport').run(ws, msg, dispatchMessage);
1834
+ }
1835
+
1836
+ function dispatchMessage(ws, msg) {
1830
1837
  if (!_requestAccess.permitMessage(ws, msg)) return;
1831
1838
  // --- Keep-alive (delegated to project-connection.js) ---
1832
1839
  // Answered before any routing so a ping can never be forwarded elsewhere.
@@ -2147,6 +2154,7 @@ function createProjectContext(opts) {
2147
2154
  });
2148
2155
  sm.setSessionHydrator(_sessionSkillCatalog.hydrate);
2149
2156
  var _sessions = attachSessions({
2157
+ splitStore: _splitGroups.store,
2150
2158
  adapters: adapters,
2151
2159
  isPendingQueuePaused: function (session) { return _pendingMessageQueue.isPaused(session); },
2152
2160
  cwd: cwd,
package/lib/public/app.js CHANGED
@@ -35,6 +35,7 @@ import { initQrCode, triggerShare } from './modules/qrcode.js';
35
35
  import { initFileBrowser, loadRootDirectory, reopenFileViewer, refreshTree, handleFsList, handleFsRead, handleDirChanged, refreshIfOpen, handleFileChanged, handleFileHistory, handleGitDiff, handleFileAt, getPendingNavigate, closeFileViewer, resetFileBrowser } from './modules/filebrowser.js';
36
36
  import { initGitPanel } from './modules/git-panel.js';
37
37
  import { initWorkerPaneLock } from './modules/worker-pane-lock.js';
38
+ import { initPaneActivation } from './modules/pane-activation.js';
38
39
  import { initAutonomousRun } from './modules/autonomous-run.js';
39
40
  import { initLoopInterview } from './modules/loop-interview.js';
40
41
  import { initTerminal, openTerminal, closeTerminal, resetTerminals, handleTermList, handleTermCreated, handleTermOutput, handleTermResized, handleTermExited, handleTermClosed, sendTerminalCommand } from './modules/terminal.js';
@@ -295,6 +296,16 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
295
296
  paneMode: paneMode,
296
297
  paneSessionId: paneSessionId,
297
298
  panePinPending: paneMode && !!paneSessionId,
299
+ paneCommittedBinding: null, panePendingBinding: null, paneReady: false, paneSuspended: false,
300
+ paneActivationGeneration: 0, paneActivationTimer: null, panePointerBinding: null, paneRichDrafts: {},
301
+ sessionRichDrafts: {},
302
+ sessionActionEpoch: 0, composerUploadingCount: 0, deferredSessionSwitch: null,
303
+ paneCallbackBinding: null, paneActionEvents: [], paneHeldKeys: {}, rewindAction: null, handoffAction: null,
304
+ authIdentityLoaded: false,
305
+ splitIdentityBlockedSocket: null,
306
+ splitIdentityHydrated: false,
307
+ paneActivationUnsupported: false,
308
+ paneBindingSessions: [],
298
309
  splitPanes: null,
299
310
  splitGroups: [],
300
311
  sessionFolders: null,
@@ -320,6 +331,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
320
331
  replayingHistory: false,
321
332
  historyReplayBatch: null,
322
333
  pendingSessionEntryNavigation: null,
334
+ sessionEntryHighlight: null,
323
335
  sessionNavigationRequestId: null,
324
336
  projectName: projectName,
325
337
  cwd: "",
@@ -1169,6 +1181,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
1169
1181
  if (document.body) document.body.classList.add('is-multi-user');
1170
1182
  }
1171
1183
  if (d.user && d.user.id) { store.set({ myUserId: d.user.id }); requestDefaultVendor(); }
1184
+ store.set({ authIdentityLoaded: true });
1172
1185
  store.set({ permissions: d.permissions || null, projectsAccessLoaded: true });
1173
1186
  if (d.mustChangePin) showForceChangePinOverlay();
1174
1187
  // Single-user mode: clear user strip skeletons immediately (no presence message will arrive)
@@ -1266,6 +1279,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
1266
1279
  initSharedBrowser();
1267
1280
  initStudioBridge();
1268
1281
  initWorkerPaneLock();
1282
+ initPaneActivation();
1269
1283
 
1270
1284
  // --- Terminal ---
1271
1285
  initTerminal({
@@ -50,7 +50,7 @@ html.contextual-pane-boot #connect-overlay { display: none !important; }
50
50
  #contextual-clay [data-conversation-notice]:not([hidden]) { display: flex; align-items: center; gap: 8px; flex-shrink: 0; padding: 6px 12px; border-bottom: 1px solid var(--border-subtle); font-size: 12px; color: var(--text-secondary); }
51
51
  #contextual-clay [data-conversation-notice] span { flex: 1; }
52
52
  #contextual-clay [data-conversation-notice] button { color: var(--accent); white-space: nowrap; }
53
- #contextual-clay [data-clay-expand] span, #contextual-clay [data-mate-new] span { display: none; }
53
+ #contextual-clay [data-clay-expand] span { display: none; }
54
54
  @media (pointer: coarse) { #contextual-clay [data-mate-history] summary, #contextual-clay [data-mate-session-list] button { min-width: 44px; min-height: 44px; } }
55
55
  @starting-style {
56
56
  #contextual-clay[data-mode="popover"]:not([hidden]) { opacity: 0; transform: translateX(-4px); }
@@ -1754,3 +1754,9 @@ body:not(.worker-pane-locked) #input-bottom-right:has(#send-btn.stop:not(:disabl
1754
1754
  margin: 8px auto 0;
1755
1755
  }
1756
1756
  #input-area > #generation-stop.hidden { display: none; }
1757
+
1758
+ .session-switch-wait {
1759
+ padding: 6px 8px;
1760
+ font-size: 12px;
1761
+ color: var(--text-secondary);
1762
+ }
@@ -927,15 +927,41 @@
927
927
  .sticky-note:not(.sticky-note-flight-away) { transition-duration: 0ms; }
928
928
  }
929
929
 
930
- /* Checklist inputs retain the card's compact reading rhythm. */
931
- .sn-task-form { display: grid; gap: 6px; }
932
- .sn-task-row { display: flex; align-items: center; gap: 6px; }
933
- .sn-task-form input { min-width: 0; width: 100%; box-sizing: border-box; border: 1px solid transparent; border-radius: 4px; background: transparent; color: inherit; font: inherit; padding: 3px 4px; }
934
- .sn-task-form input:hover, .sn-task-form input:focus { border-color: color-mix(in srgb, var(--note-ink) 25%, transparent); }
935
- .sn-task-title { font-weight: 600 !important; }
936
- .sn-task-form button { color: inherit; background: transparent; border: 0; font: inherit; cursor: pointer; }
937
- .sn-task-form .sn-check { flex-shrink: 0; }
938
- .sn-task-form .sn-task-save { justify-self: start; padding: 4px 8px; border: 1px solid color-mix(in srgb, var(--note-ink) 25%, transparent); border-radius: 4px; }
939
- .sn-task-form button:focus-visible, .sn-task-form input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
930
+ /* Checklist writing stays on the stationery surface. */
931
+ .sn-task-form { display: flex; flex-direction: column; gap: 2px; min-height: 100%; box-sizing: border-box; padding: 2px 2px 0; }
932
+ .sn-task-form textarea { display: block; min-width: 0; width: 100%; box-sizing: border-box; border: 1px solid transparent; border-radius: 4px; background: transparent; color: inherit; font: inherit; line-height: 1.5; padding: 4px; margin: 0; resize: none; field-sizing: content; overflow: hidden; overflow-wrap: anywhere; }
933
+ .sn-task-form textarea:hover { background: color-mix(in srgb, var(--note-ink) 4%, transparent); }
934
+ .sn-task-form textarea:focus { background: color-mix(in srgb, var(--note-ink) 5%, transparent); }
935
+ .sn-task-form textarea::placeholder { color: inherit; opacity: .58; }
936
+ .sn-task-form .sn-task-title { font-size: 16px; font-weight: 650; line-height: 1.35; letter-spacing: -.02em; padding: 2px 4px; }
937
+ .sn-task-summary { font-size: 11px; opacity: .65; padding: 0 4px 8px; }
938
+ .sn-task-row { display: grid; grid-template-columns: 26px minmax(0, 1fr) 26px; align-items: start; gap: 1px; }
939
+ .sn-task-form button { color: inherit; background: transparent; border: 0; font: inherit; cursor: pointer; border-radius: 4px; }
940
+ .sn-task-form .sn-check { width: 26px; height: 29px; margin: 0; padding: 0; opacity: 1; transform: none; display: grid; place-items: center; }
941
+ .sn-task-box { display: grid; place-items: center; width: 15px; height: 15px; box-sizing: border-box; border: 1.25px solid color-mix(in srgb, var(--note-ink) 55%, transparent); border-radius: 4px; }
942
+ .sn-task-box svg { width: 13px; height: 13px; visibility: hidden; }
943
+ .sn-task-form .checked .sn-task-box { background: var(--note-ink); border-color: var(--note-ink); color: var(--note-surface); }
944
+ .sn-task-form .checked .sn-task-box svg { visibility: visible; }
945
+ .sn-task-row.is-complete textarea:not(:focus) { text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--note-ink) 45%, transparent); color: color-mix(in srgb, var(--note-ink) 68%, transparent); }
946
+ .sn-task-remove { width: 26px; height: 29px; padding: 0; display: grid; place-items: center; opacity: 0; }
947
+ .sn-task-remove .lucide, .sn-task-plus .lucide { width: 14px; height: 14px; }
948
+ .sn-task-row:hover .sn-task-remove, .sn-task-row:focus-within .sn-task-remove { opacity: .65; }
949
+ .sn-task-form button:hover { background: color-mix(in srgb, var(--note-ink) 8%, transparent); }
950
+ .sn-task-form button:active:not(:disabled) { transform: translateY(1px); }
951
+ .sn-task-add { display: grid; grid-template-columns: 26px minmax(0, 1fr); align-items: start; gap: 1px; margin-top: 4px; }
952
+ .sn-task-plus { display: grid; place-items: center; height: 29px; opacity: .55; }
953
+ .sn-task-footer { display: flex; align-items: center; flex-wrap: wrap; justify-content: space-between; gap: 4px 8px; margin-top: auto; padding: 10px 4px 2px; }
954
+ .sn-task-feedback { flex: 1 1 100px; font-size: 11px; line-height: 1.4; opacity: .75; overflow-wrap: anywhere; }
955
+ .sn-task-form .sn-task-save { font-size: 11px; font-weight: 550; min-height: 28px; padding: 3px 8px; border: 1px solid color-mix(in srgb, var(--note-ink) 20%, transparent); }
956
+ .sn-task-form button:disabled { cursor: default; opacity: .45; }
957
+ .sn-task-form button:focus-visible, .sn-task-form textarea:focus-visible { outline: 2px solid var(--note-ink); outline-offset: -1px; }
940
958
  .sticky-note-header .sticky-note-md-btn[hidden] { display: none !important; }
941
- .sn-task-remove .lucide { width: 13px; height: 13px; }
959
+ @media (hover: none), (pointer: coarse) {
960
+ .sn-task-row { grid-template-columns: 36px minmax(0, 1fr) 36px; }
961
+ .sn-task-form .sn-check, .sn-task-remove { width: 36px; height: 36px; }
962
+ .sn-task-remove { opacity: .6; }
963
+ .sn-task-add { grid-template-columns: 36px minmax(0, 1fr); }
964
+ .sn-task-plus { height: 36px; }
965
+ .sn-task-form textarea { padding-top: 7px; padding-bottom: 7px; }
966
+ .sn-task-form .sn-task-save { min-height: 36px; }
967
+ }
@@ -26,6 +26,7 @@ import { clearMcpPermissionModePending } from './mcp-ui.js';
26
26
  import { createWebSocketLifecycle } from './websocket-lifecycle.js';
27
27
  import { createWebSocketWatchdog } from './websocket-watchdog.js';
28
28
  import { resetHistoryReplayBatch } from './history-replay-batch.js';
29
+ import { paneOutgoing } from './pane-activation.js';
29
30
 
30
31
  var connectOverlay = null;
31
32
  var hasConnectedOnce = false;
@@ -371,6 +372,8 @@ export function connect() {
371
372
  var currentWs = getWs();
372
373
  var _origSend = currentWs.send.bind(currentWs);
373
374
  currentWs.send = function (data) {
375
+ data = paneOutgoing(data);
376
+ if (data === null) return false;
374
377
  blinkIO();
375
378
  return _origSend(data);
376
379
  };
@@ -17,6 +17,8 @@ import { handleDefaultAiMessage } from './default-ai.js';
17
17
  import { handleCursorSharingMessage } from './cursor-sharing-preference.js';
18
18
  import { handlePendingMessageQueueMessage } from './pending-message-queue.js';
19
19
  import { receiveHistoryReplay } from './history-replay-batch.js';
20
+ import { filterPaneActivation } from './pane-activation.js';
21
+ import { deferSessionSwitch } from './session-switch-defer.js';
20
22
  import { handleProjectMateCreationMessage } from './project-mate-creation-workspace.js';
21
23
  import { handleProjectDebatePlanningMessage } from './project-debate-planning-workspace.js';
22
24
 
@@ -94,6 +96,17 @@ export function handleHomeProtocolMessage(msg) {
94
96
  }
95
97
 
96
98
  export function processMessage(msg) {
99
+ if (filterPaneActivation(msg)) return;
100
+ if (deferSessionSwitch(msg)) return;
101
+ if (msg.type === 'pane_history_batch') {
102
+ if (!msg.paneActivation || !Array.isArray(msg.items) || msg.items.length > 100) return;
103
+ msg.items.forEach(function (item) {
104
+ // Every original event keeps its order/index and goes through the same
105
+ // stale-binding filter and replay handler as an individual socket event.
106
+ if (item && item.type !== 'pane_history_batch') processMessage(Object.assign({}, item, { paneActivation: msg.paneActivation }));
107
+ });
108
+ return;
109
+ }
97
110
  receiveHistoryReplay(msg, dispatchMessage);
98
111
  }
99
112
 
@@ -1,4 +1,7 @@
1
1
  import { handleStudioDocumentMessage } from './clay-studio-document.js';
2
+ import { saveSessionRichDraft, restoreSessionRichDraft } from './session-rich-drafts.js';
3
+ import { refreshPaneActivation, primePaneSession } from './pane-activation.js';
4
+ import { paneCallbackCurrent } from './pane-binding.js';
2
5
  import { handleClayControlActivity } from './clay-control-activity.js';
3
6
  import { handleContextualWorkspaceMessage } from './contextual-clay-workspace.js';
4
7
  import { handleContextualSessionMessage } from './contextual-clay-session.js';
@@ -268,6 +271,7 @@ export function processMessage(msg) {
268
271
  break;
269
272
 
270
273
  case "history_done":
274
+ var historyPaintEpoch = store.get('sessionActionEpoch') || 0;
271
275
  if (messagesEl && msg.sessionId === store.get("activeSessionId") && msg.sessionOriginId === store.get("sessionOriginId")) { messagesEl.dataset.controlSessionId = String(msg.sessionId); messagesEl.dataset.controlSessionOrigin = msg.sessionOriginId; messagesEl.dataset.controlHistoryRevision = msg.controlHistoryRevision || ""; }
272
276
  store.set({ replayingHistory: false });
273
277
  resumeThinkingAfterReplay(store.get('sessionIsProcessing'));
@@ -350,6 +354,7 @@ export function processMessage(msg) {
350
354
  // Scroll to tool element if navigating from file edit history
351
355
  if (hasNavTarget) {
352
356
  requestAnimationFrame(function() {
357
+ if (historyPaintEpoch !== (store.get('sessionActionEpoch') || 0) || !paneCallbackCurrent(msg.paneActivation)) return;
353
358
  // Prefer scrolling to the exact tool element
354
359
  var target = nav.toolId ? messagesEl.querySelector('[data-tool-id="' + nav.toolId + '"]') : null;
355
360
  if (!target && nav.assistantUuid) {
@@ -365,7 +370,7 @@ export function processMessage(msg) {
365
370
  }
366
371
  });
367
372
  }
368
- requestAnimationFrame(function () { handlePaletteHistoryDone(); });
373
+ requestAnimationFrame(function () { if (historyPaintEpoch === (store.get('sessionActionEpoch') || 0) && paneCallbackCurrent(msg.paneActivation)) handlePaletteHistoryDone(); });
369
374
  break;
370
375
 
371
376
  case "restore_mate_dm":
@@ -777,9 +782,11 @@ export function processMessage(msg) {
777
782
  markProjectSessionListHydrated();
778
783
  renderMateSessionList(msg.sessions || []);
779
784
  renderSessionList(msg.sessions || []);
785
+ store.set({ paneBindingSessions: msg.sessions || [] });
786
+ refreshPaneActivation();
780
787
  syncGithubSessions(msg.sessions || []);
781
788
  syncPaneTitles();
782
- var panePin = resolvePaneSession(store.get('paneMode'), store.get('panePinPending'), store.get('paneSessionId'), msg.sessions);
789
+ var panePin = resolvePaneSession(store.get('paneMode') && !primePaneSession(), store.get('panePinPending'), store.get('paneSessionId'), msg.sessions);
783
790
  if (panePin.consumed) {
784
791
  store.set({ panePinPending: false });
785
792
  if (panePin.sessionId && getWs() && getWs().readyState === 1) {
@@ -906,6 +913,8 @@ export function processMessage(msg) {
906
913
  break;
907
914
 
908
915
  case "session_switched":
916
+ saveSessionRichDraft();
917
+ store.set({ sessionActionEpoch: (store.get('sessionActionEpoch') || 0) + 1 });
909
918
  clearPairResultStatus();
910
919
  resetStreamBuffer();
911
920
  syncAutonomousRunForSession(msg.autonomousRun || null);
@@ -923,7 +932,7 @@ export function processMessage(msg) {
923
932
  ? msg.runtimeTerminalId
924
933
  : (typeof msg.terminalId === "number" ? msg.terminalId : null);
925
934
  var _sessionRole = msg.sessionRole === "worker" ? "worker" : "driver";
926
- store.set({ activeSessionId: msg.id, sessionOriginId: msg.sessionOriginId || null, cliSessionId: msg.cliSessionId || null, currentModel: msg.model || "", currentEffort: msg.effort || store.get('currentEffort'), vendorCapabilities: msg.capabilities || {}, sessionIsProcessing: !!msg.isProcessing, turnActivity: null, activeSessionMode: _effectiveMode, activeTerminalId: _effectiveTerminalId, sessionHasHistory: !!msg.hasHistory, sessionVendorBound: !!msg.vendor || !!msg.hasHistory, sessionFullAccess: msg.permissionMode === "bypassPermissions", currentMode: msg.permissionMode || store.get('currentMode'), effectivePermissionMode: msg.effectivePermissionMode || null, permissionCapabilities: msg.permissionCapabilities || { auto: false, mcpOverride: false }, mcpPermissionModeOverrides: msg.mcpPermissionModeOverrides || {}, activeSessionMateId: _sessionRole === "worker" ? null : (store.get('activeProjectMateId') || null), activeSessionMateName: _sessionRole === "worker" ? "" : (store.get('activeProjectMateName') || "") });
935
+ store.set({ activeSessionId: msg.id, sessionOriginId: msg.sessionOriginId || null, cliSessionId: msg.cliSessionId || null, currentModel: msg.model || "", currentEffort: msg.effort || '', vendorCapabilities: msg.capabilities || {}, sessionIsProcessing: !!msg.isProcessing, turnActivity: null, activeSessionMode: _effectiveMode, activeTerminalId: _effectiveTerminalId, sessionHasHistory: !!msg.hasHistory, sessionVendorBound: !!msg.vendor || !!msg.hasHistory, sessionFullAccess: msg.permissionMode === "bypassPermissions", currentMode: msg.permissionMode || 'default', effectivePermissionMode: msg.effectivePermissionMode || null, permissionCapabilities: msg.permissionCapabilities || { auto: false, mcpOverride: false }, mcpPermissionModeOverrides: msg.mcpPermissionModeOverrides || {}, activeSessionMateId: _sessionRole === "worker" ? null : (store.get('activeProjectMateId') || null), activeSessionMateName: _sessionRole === "worker" ? "" : (store.get('activeProjectMateName') || "") });
927
936
  handleScheduledTaskSessionSwitched(msg);
928
937
  requestLoopInterviewState();
929
938
  activateDeliverySession(msg.id, msg.cliSessionId || null, msg.sessionOriginId || null);
@@ -998,14 +1007,19 @@ export function processMessage(msg) {
998
1007
  // Restore draft for incoming session
999
1008
  var draft = store.get('sessionDrafts')[store.get('activeSessionId')] || "";
1000
1009
  inputEl.value = draft;
1010
+ restoreSessionRichDraft();
1001
1011
  autoResize();
1002
1012
  if (!("ontouchstart" in window)) {
1003
1013
  inputEl.focus();
1004
1014
  }
1005
1015
  // Reload survival: a restored active session that is a split-group
1006
1016
  // member reopens its group (no-op when a split is already open).
1007
- finishProjectSessionActivation();
1008
- maybeRestoreSplitGroup();
1017
+ var deferredSwitch = store.get('deferredSessionSwitch');
1018
+ if (!deferredSwitch || !deferredSwitch.draining || deferredSwitch.finalSwitch === msg) {
1019
+ finishProjectSessionActivation();
1020
+ maybeRestoreSplitGroup();
1021
+ handlePaletteSessionSwitch(true);
1022
+ }
1009
1023
  break;
1010
1024
 
1011
1025
  case "session_full_access_changed":
@@ -1350,6 +1364,11 @@ export function processMessage(msg) {
1350
1364
  stopUrgentBlink();
1351
1365
  break;
1352
1366
 
1367
+ case "ask_user_pending":
1368
+ renderAskUserQuestion(msg.toolId, msg.input);
1369
+ startUrgentBlink();
1370
+ break;
1371
+
1353
1372
  case "permission_request_pending":
1354
1373
  store.set({ turnActivity: null });
1355
1374
  setActivity(null);
@@ -1770,6 +1789,7 @@ export function processMessage(msg) {
1770
1789
  store.set({ pendingTermCommand: null });
1771
1790
  // Small delay to let terminal initialize
1772
1791
  setTimeout(function() {
1792
+ if (!paneCallbackCurrent(msg.paneActivation)) return;
1773
1793
  sendTerminalCommand(cmd);
1774
1794
  }, 300);
1775
1795
  }
@@ -3,6 +3,8 @@
3
3
 
4
4
  import { refreshIcons, iconHtml } from './icons.js';
5
5
  import { getWs } from './ws-ref.js';
6
+ import { store } from './store.js';
7
+ import { sessionCallback } from './pane-binding.js';
6
8
  import { updateBrowserTabList } from './context-sources.js';
7
9
  import { setExtensionConnected } from './mcp-ui.js';
8
10
  import { initPasteModal } from './paste-modal.js';
@@ -270,7 +272,7 @@ export function showConfirm(text, onConfirm, okLabel, destructive, onCancel) {
270
272
  confirmCancelCallback = null;
271
273
  if (replaced) replaced();
272
274
  confirmText.textContent = text;
273
- confirmCallback = onConfirm;
275
+ confirmCallback = store.get('paneCommittedBinding') ? sessionCallback(onConfirm) : onConfirm;
274
276
  confirmCancelCallback = onCancel || null;
275
277
  confirmOk.textContent = okLabel || "Delete";
276
278
  confirmOk.className = "confirm-btn " + (destructive === false ? "confirm-ok" : "confirm-delete");
@@ -1,4 +1,5 @@
1
1
  import { prepareContextualWorkspaceDeparture } from './contextual-clay-workspace.js';
2
+ import { checkComposerDeparture, saveSessionRichDraft } from './session-rich-drafts.js';
2
3
  // app-projects.js - Project list, switching, add/remove project modals
3
4
  // Extracted from app.js (PR-29)
4
5
 
@@ -391,6 +392,10 @@ export function resetClientState() {
391
392
 
392
393
  export function switchProject(slug) {
393
394
  if (!slug) return;
395
+ if (slug !== store.get('currentSlug')) {
396
+ try { checkComposerDeparture(); saveSessionRichDraft(); }
397
+ catch (error) { showToast(error.message, 'warn'); return; }
398
+ }
394
399
  if (prepareContextualWorkspaceDeparture(slug) === false) return;
395
400
  var target = cachedProjects.find(function (project) { return project.slug === slug; });
396
401
  if (target && target.contentAccess === false && target.administrativeAccess === true) {
@@ -1,5 +1,4 @@
1
1
  import { observeNavigationRendering } from './clay-navigation-render.js';
2
- import { dismissContextualClay } from './contextual-clay.js';
3
2
  import { showClayNavigationActivity, clearClayControlActivity } from './clay-control-activity.js';
4
3
  import { store } from './store.js';
5
4
  import { getWs } from './ws-ref.js';
@@ -23,8 +22,6 @@ export function handleOwnerNavigation(msg) {
23
22
  var status = 'refused';
24
23
  if (accepted && msg.target && typeof msg.target.projectSlug === 'string' && Number.isSafeInteger(msg.target.sessionId)) {
25
24
  try {
26
- var contextual = store.get('contextualClay') || {};
27
- if (msg.target.contextualSourceMateId && contextual.mateId === msg.target.contextualSourceMateId && contextual.mode === 'popover') dismissContextualClay(false);
28
25
  showClayNavigationActivity(msg.target, false); navigateToSession(msg.target, null, msg.target.entryIndex); status = 'accepted';
29
26
  } catch (error) { showClayNavigationActivity(msg.target, true); status = 'failed'; }
30
27
  }
@@ -1,5 +1,6 @@
1
1
  import { forwardDocumentSource } from './clay-studio-document.js';
2
2
  import { store } from './store.js';
3
+ import { paneMessageBinding, livePaneBinding } from './pane-binding.js';
3
4
  import { getWs } from './ws-ref.js';
4
5
  import { contextualMateStamp } from './contextual-mate-identity.js';
5
6
  import { visibleTarget } from './clay-control-target-visibility.js';
@@ -34,7 +35,7 @@ export function forwardStudioPresentation(message, type) {
34
35
  if (Object.keys(receipts).length >= 100) return true;
35
36
  if (evidence) receipts[evidence.id] = { evidence: evidence, socket: getWs() };
36
37
  store.set({ clayStudioRelays: receipts });
37
- window.parent.postMessage({ type: type, message: message, studioSource: frame.clayStudioSource.identity() }, location.origin);
38
+ window.parent.postMessage({ type: type, message: message, paneBinding: paneMessageBinding(), studioSource: frame.clayStudioSource.identity() }, location.origin);
38
39
  return true;
39
40
  }
40
41
  export function trustedStudioFrame(event, data) {
@@ -45,6 +46,7 @@ export function trustedStudioFrame(event, data) {
45
46
  if (!live || !live.connected || live.ownerId !== (store.get('myUserId') || null) || live.ownerId !== expected.ownerId || live.slug !== expected.slug || live.sessionId !== expected.sessionId || live.origin !== expected.origin || live.runtime !== expected.runtime) return null;
46
47
  if (new URL(frame.src, location.href).pathname !== '/p/' + encodeURIComponent(live.slug) + '/') return null;
47
48
  if (frame.classList.contains('split-pane-frame')) {
49
+ if (frame.clayPane && !livePaneBinding(frame, data)) return null;
48
50
  var split = store.get('splitPanes');
49
51
  if (!split || !split.panes.some(function (pane) { return pane.slug === live.slug && String(pane.sessionId) === String(live.sessionId); })) return null;
50
52
  } else {