Skip to content

Commit 89f8b13

Browse files
authored
fix(workers): introduce new Workers with an open viewer (#602)
Automatically open the first newly created Worker's viewer for the selected Driver and highlight its pill, while preserving composer focus. Closing it leaves it closed during repeated creation messages and live output; clicking the pill reopens it. Additional Workers remain pills, and an existing viewer is never replaced. Pending introductions wait for exact metadata and pane readiness, reject changed owner/project/connection and replaced or revoked Worker identities, and stay inactive during history replay and inside child/DM contexts. Introduction receipts are bounded in page memory. Validation: 37 focused Node tests and four isolated HTTP/WebSocket browser checks passed. Asset build, freshness, syntax and diff checks passed. Existing settings-shell duplicate-key warning remains. Merge requested without waiting for CI.
2 parents 5a9ed51 + e882de6 commit 89f8b13

5 files changed

Lines changed: 149 additions & 6 deletions

‎lib/public/modules/split-view-workers.js‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,54 @@ export function workerRemovalTarget(container, driverOrigin, workerOrigin, gener
7171
bindingGeneration: source.binding.generation, bindingRequestId: source.binding.requestId,
7272
viewRevision: store.get('projectSplitViewRevision'), socketGeneration: store.get('projectSplitViewSocketGeneration') };
7373
}
74+
// One introduction per Driver in this page, independent of transcript refreshes.
75+
export function showCreatedWorkerViewer(group) {
76+
var state = store.snap(), roles = splitGroupRoles(group);
77+
if (!roles || state.paneMode || state.dmMode || state.replayingHistory || !state.connected ||
78+
state.activeSessionId !== roles.driverId || document.hidden) return;
79+
var workerId = roles.workerIds[roles.workerIds.length - 1];
80+
var worker = sessions().find(function (item) { return item.id === workerId; });
81+
var anchors = group.pairAnchors, anchor = anchors && (anchors.workers || [anchors.worker])[roles.workerIds.length - 1];
82+
var origin = worker && worker.sessionOriginId || anchor && anchor.origin;
83+
if (!origin) return;
84+
var driverIdentity = sessions().find(function (item) { return item.id === roles.driverId; });
85+
var driverOrigin = driverIdentity && driverIdentity.sessionOriginId || anchors && anchors.driver && anchors.driver.origin;
86+
if (!driverOrigin) return;
87+
var key = JSON.stringify([state.myUserId, state.currentSlug, driverOrigin]);
88+
var seen = state.introducedWorkerDrivers || [];
89+
if (seen.indexOf(key) >= 0) return;
90+
store.set({ introducedWorkerDrivers: seen.concat(key).slice(-64) });
91+
if (state.createdWorkerViewer || state.splitViewWorkerConversation) return;
92+
store.set({ createdWorkerViewer: { driverId: roles.driverId, driverOrigin: driverOrigin, workerId: workerId, workerOrigin: origin,
93+
generation: worker ? worker.workerGeneration : null,
94+
ownerId: state.myUserId, slug: state.currentSlug, socketGeneration: state.projectSplitViewSocketGeneration } });
95+
var driver = sessions().find(function (item) { return item.id === roles.driverId; });
96+
var pane = driver && document.querySelector('.split-pane[data-session-origin="' + CSS.escape(driver.sessionOriginId) + '"]');
97+
if (pane) renderPaneWorkers(pane, driver);
98+
}
99+
function introduceWorkerViewer(container, driver) {
100+
var pending = store.get('createdWorkerViewer'), state = store.snap();
101+
if (!pending || pending.driverId !== driver.id) return;
102+
var worker = sessions().find(function (item) { return item.id === pending.workerId; });
103+
if (!worker) return;
104+
store.set({ createdWorkerViewer: null });
105+
var group = (state.splitGroups || []).find(function (item) {
106+
var roles = splitGroupRoles(item);
107+
return roles && roles.driverId === driver.id && roles.workerIds.indexOf(worker.id) >= 0;
108+
});
109+
if (!group || driver.sessionOriginId !== pending.driverOrigin || worker.sessionOriginId !== pending.workerOrigin || pending.generation !== null && worker.workerGeneration !== pending.generation ||
110+
state.splitViewWorkerConversation || document.hidden || !container.isConnected || container.hidden || container.inert ||
111+
driver.hidden || worker.hidden || exactWorker(driver, worker.sessionOriginId, worker.workerGeneration) !== worker) return;
112+
var chip = Array.from(container.querySelectorAll('.split-view-worker-chip')).find(function (item) { return item.dataset.workerOrigin === worker.sessionOriginId; });
113+
if (chip) open(chip, driver, worker, false);
114+
}
115+
function syncWorkerSelection() {
116+
var state = store.get('splitViewWorkerConversation');
117+
document.querySelectorAll('.split-view-worker-chip').forEach(function (chip) {
118+
chip.setAttribute('aria-expanded', String(!!state && chip.dataset.workerOrigin === state.workerOriginId &&
119+
chip.dataset.workerGeneration === String(state.generation)));
120+
});
121+
}
74122
function popover() { return document.getElementById("split-view-worker-popover"); }
75123
function close(returnFocus) {
76124
var state = store.get('splitViewWorkerConversation');
@@ -84,6 +132,7 @@ function close(returnFocus) {
84132
if (state.fillTimer) clearTimeout(state.fillTimer);
85133
if (state.requestTimer) clearTimeout(state.requestTimer);
86134
store.set({ splitViewWorkerConversation: null });
135+
syncWorkerSelection();
87136
var element = popover(); if (element) element.remove();
88137
if (returnFocus) {
89138
var chip = document.querySelector('.split-pane[data-session-origin="' + CSS.escape(state.driverOriginId) + '"] [data-worker-origin="' + CSS.escape(state.workerOriginId) + '"]');
@@ -135,7 +184,7 @@ function requestPage(state, before, append, refresh) {
135184
}
136185
}
137186
}
138-
function open(chip, driver, worker) {
187+
function open(chip, driver, worker, takeFocus) {
139188
var existing = store.get('splitViewWorkerConversation');
140189
if (existing && existing.driverOriginId === driver.sessionOriginId && existing.workerOriginId === worker.sessionOriginId && existing.generation === worker.workerGeneration) { close(true); return; }
141190
if (existing) close(false);
@@ -146,7 +195,8 @@ function open(chip, driver, worker) {
146195
store.set({ splitViewWorkerConversation: state }); ensurePopover(); renderContent();
147196
var element = popover(); position(element, chip);
148197
requestPage(state, undefined, false);
149-
var closeButton = element && element.querySelector('[aria-label="Close Worker conversation"]'); if (closeButton) closeButton.focus();
198+
syncWorkerSelection();
199+
var closeButton = element && element.querySelector('[aria-label="Close Worker conversation"]'); if (closeButton && takeFocus !== false) closeButton.focus();
150200
}
151201
function ensurePopover() {
152202
var state = store.get('splitViewWorkerConversation');
@@ -368,6 +418,7 @@ export function renderPaneWorkers(container, driver) {
368418
});
369419
root.appendChild(controls);
370420
}
421+
introduceWorkerViewer(container, driver);
371422
var conversation = store.get('splitViewWorkerConversation');
372423
if (conversation) ensurePopover();
373424
}
@@ -386,6 +437,11 @@ window.addEventListener("scroll", function (event) {
386437
reanchorPopover();
387438
}, true);
388439
store.subscribe(function (state, previous) {
440+
var introduction = state.createdWorkerViewer;
441+
if (introduction && (!state.connected || introduction.slug !== state.currentSlug || introduction.ownerId !== state.myUserId ||
442+
introduction.socketGeneration !== state.projectSplitViewSocketGeneration || introduction.driverId !== state.activeSessionId)) {
443+
store.set({ createdWorkerViewer: null });
444+
}
389445
if (state.paneBindingSessions !== previous.paneBindingSessions || state.connected !== previous.connected) {
390446
document.querySelectorAll('.split-view-worker-chip').forEach(function (chip) {
391447
var worker = sessions().find(function (item) { return item.sessionOriginId === chip.dataset.workerOrigin; });

‎lib/public/modules/worker-created-presentation.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// New execution pairs keep the Driver's current presentation; Workers live in pills.
2+
import { showCreatedWorkerViewer } from './split-view-workers.js';
23
import { store } from './store.js';
34
import { getWs } from './ws-ref.js';
45
import { getCachedSessions } from './sidebar-sessions.js';
@@ -24,6 +25,7 @@ function reconcileCreatedWorkerPresentation() {
2425
(session.ownerId || null) === (state.myUserId || null); });
2526
if (state.sessionListProjectSlug !== state.currentSlug || !driver || !driver.sessionOriginId || !getWs() || getWs().readyState !== 1) return false;
2627
store.set({ createdWorkerPresentation: null });
28+
showCreatedWorkerViewer(pending.group);
2729
if (state.activeSessionId === driver.id && !state.splitPanes) return true;
2830
var view = (state.projectSplitViews || []).find(function (item) {
2931
return item.sessions.some(function (ref) { return ref.id === driver.id && ref.sessionOriginId === driver.sessionOriginId; });

‎test/split-view-worker-retention-feedback.test.js‎

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ function fixture() {
4040
['store', 'pane-binding', 'split-pane-retention', 'split-group-helpers', 'split-view-worker-scroll', 'split-view-workers'].forEach(function (name) {
4141
vm.runInContext(fs.readFileSync('lib/public/modules/' + name + '.js', 'utf8').replace(/^import .*;\n/gm, '').replace(/^export \{[^}]+\};?$/gm, '').replace(/^export /gm, ''), context);
4242
});
43-
context.createStore({ connected: true, authIdentityLoaded: true, currentSlug: 'p', activeProjectSlug: 'p', sessionListProjectSlug: 'p', myUserId: 'owner', currentProjectOwnerId: 'owner',
43+
context.createStore({ connected: true, authIdentityLoaded: true, currentSlug: 'p', activeSessionId: 1, activeProjectSlug: 'p', sessionListProjectSlug: 'p', myUserId: 'owner', currentProjectOwnerId: 'owner',
4444
projectSplitViewSocketGeneration: 1, paneBindingSessions: sessions, projectSplitViewActiveId: 'view', projectSplitViews: [{ id: 'view', sessions: [driver] }],
4545
splitGroups: [{ id: 'pair', members: [1,2], pair: { version: 2, driverId: 1, workerIds: [2] } }, { id: 'cached-pair', members: [4,5], pair: { version: 2, driverId: 4, workerIds: [5] } }] });
4646
function makePane(session) {
@@ -141,3 +141,79 @@ test('revoking and restoring pair metadata preserves active Driver identity whil
141141
if (boundary === 'owner') assert.equal(f.pane.isConnected, false);
142142
});
143143
});
144+
145+
test('creation opens the first exact Worker once, selects its pill and preserves Driver focus', function () {
146+
var f = fixture(), api = f.context, group = api.store.get('splitGroups')[0];
147+
var composer = f.document.createElement('textarea'); f.document.body.appendChild(composer); composer.focus();
148+
api.showCreatedWorkerViewer(group);
149+
assert.equal(api.store.get('splitViewWorkerConversation').workerOriginId, 'worker-2');
150+
assert.equal(f.document.activeElement, composer);
151+
assert.equal(f.pane.querySelectorAll('.split-view-worker-chip')[0].attrs['aria-expanded'], 'true');
152+
assert.equal(f.sends(), 1);
153+
api.close(false);
154+
assert.equal(f.pane.querySelectorAll('.split-view-worker-chip')[0].attrs['aria-expanded'], 'false');
155+
api.showCreatedWorkerViewer(group); api.renderPaneWorkers(f.pane, f.sessions[0]);
156+
assert.equal(api.store.get('splitViewWorkerConversation'), null, 'duplicate creation and rerender cannot reopen dismissal');
157+
api.showCreatedWorkerViewer({ members: [1,2,3], pair: { version: 2, driverId: 1, workerIds: [2,3] } });
158+
assert.equal(api.store.get('splitViewWorkerConversation'), null, 'additional Workers remain as pills');
159+
f.click(0);
160+
assert.equal(api.store.get('splitViewWorkerConversation').workerOriginId, 'worker-2', 'explicit click still opens the viewer');
161+
});
162+
test('creation waits for bound pane readiness and never opens for old history', function () {
163+
var f = fixture(), api = f.context, frame = f.pane.querySelector('.split-pane-frame'), identity = frame.clayPane.identity;
164+
frame.clayPane.identity = function () { return Object.assign({}, identity(), { ready: false }); };
165+
api.showCreatedWorkerViewer(api.store.get('splitGroups')[0]);
166+
assert.ok(api.store.get('createdWorkerViewer')); assert.equal(f.sends(), 0);
167+
frame.clayPane.identity = identity; api.renderPaneWorkers(f.pane, f.sessions[0]);
168+
assert.equal(api.store.get('createdWorkerViewer'), null);
169+
assert.equal(api.store.get('splitViewWorkerConversation').workerOriginId, 'worker-2');
170+
var replay = fixture(); replay.context.store.set({ replayingHistory: true });
171+
replay.context.showCreatedWorkerViewer(replay.context.store.get('splitGroups')[0]);
172+
assert.equal(replay.sends(), 0); assert.equal(replay.context.store.get('createdWorkerViewer'), undefined);
173+
});
174+
['myUserId', 'currentSlug', 'projectSplitViewSocketGeneration', 'activeSessionId', 'connected'].forEach(function (field) {
175+
test('pending introduction cancels when ' + field + ' changes before readiness', function () {
176+
var f = fixture(), api = f.context, frame = f.pane.querySelector('.split-pane-frame'), identity = frame.clayPane.identity;
177+
frame.clayPane.identity = function () { return Object.assign({}, identity(), { ready: false }); };
178+
api.showCreatedWorkerViewer(api.store.get('splitGroups')[0]);
179+
var change = {}; change[field] = field === 'connected' ? false : 'changed'; api.store.set(change);
180+
assert.equal(api.store.get('createdWorkerViewer'), null); assert.equal(f.sends(), 0);
181+
});
182+
});
183+
[function (f) { f.sessions[1].sessionOriginId = 'replaced'; },
184+
function (f) { f.sessions[1].workerGeneration++; },
185+
function (f) { f.sessions[1].ownerId = 'foreign'; },
186+
function (f) { f.context.store.set({ splitGroups: [] }); }].forEach(function (change, index) {
187+
test('pending introduction rejects replaced or revoked Worker ' + index, function () {
188+
var f = fixture(), api = f.context, frame = f.pane.querySelector('.split-pane-frame'), identity = frame.clayPane.identity;
189+
frame.clayPane.identity = function () { return Object.assign({}, identity(), { ready: false }); };
190+
api.showCreatedWorkerViewer(api.store.get('splitGroups')[0]); change(f);
191+
frame.clayPane.identity = identity; api.renderPaneWorkers(f.pane, f.sessions[0]);
192+
assert.equal(f.sends(), 0); assert.equal(!!api.store.get('splitViewWorkerConversation'), false);
193+
});
194+
});
195+
196+
test('an existing viewer is never replaced and pane/DM/hidden contexts never introduce Workers', function () {
197+
var f = fixture(), api = f.context; f.click(1);
198+
api.showCreatedWorkerViewer(api.store.get('splitGroups')[0]);
199+
assert.equal(api.store.get('splitViewWorkerConversation').workerOriginId, 'worker-3');
200+
['paneMode', 'dmMode', 'hidden'].forEach(function (field) {
201+
var blocked = fixture();
202+
if (field === 'hidden') blocked.document.hidden = true;
203+
else { var change = {}; change[field] = true; blocked.context.store.set(change); }
204+
blocked.context.showCreatedWorkerViewer(blocked.context.store.get('splitGroups')[0]);
205+
assert.equal(blocked.sends(), 0);
206+
});
207+
});
208+
test('exact creation origin waits for late Worker metadata and keeps only the first pending introduction', function () {
209+
var f = fixture(), api = f.context, worker = f.sessions.splice(1, 1)[0];
210+
var group = Object.assign({}, api.store.get('splitGroups')[0], { pairAnchors: { version: 2, driver: { origin: 'driver' }, workers: [{ origin: worker.sessionOriginId }] } });
211+
api.showCreatedWorkerViewer(group);
212+
assert.equal(api.store.get('createdWorkerViewer').workerOrigin, worker.sessionOriginId);
213+
assert.equal(f.sends(), 0);
214+
api.showCreatedWorkerViewer({ members: [1,2,3], pair: { version: 2, driverId: 1, workerIds: [2,3] } });
215+
assert.equal(api.store.get('createdWorkerViewer').workerId, 2);
216+
f.sessions.splice(1, 0, worker); api.renderPaneWorkers(f.pane, f.sessions[0]);
217+
assert.equal(api.store.get('splitViewWorkerConversation').workerOriginId, worker.sessionOriginId);
218+
assert.equal(f.sends(), 1);
219+
});

‎test/worker-created-presentation.test.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
var test = require('node:test'), assert = require('node:assert/strict'), fs = require('node:fs'), vm = require('node:vm');
22
function fixture(extra) {
33
var state = Object.assign({ currentSlug: 'p', sessionListProjectSlug: 'p', myUserId: 'owner', activeSessionId: 1, connected: true, projectSplitViewSocketGeneration: 2 }, extra), subscribers = [], calls = [], sessions = [];
4-
var api = vm.createContext({ store: { snap: function () { return state; }, get: function (key) { return state[key]; },
4+
var api = vm.createContext({ showCreatedWorkerViewer: function () {}, store: { snap: function () { return state; }, get: function (key) { return state[key]; },
55
set: function (change) { var previous = Object.assign({}, state); Object.assign(state, change); subscribers.forEach(function (fn) { fn(state, previous); }); },
66
subscribe: function (fn) { subscribers.push(fn); } },
77
getWs: function () { return { readyState: 1 }; }, getCachedSessions: function () { return sessions; },

‎test/worker-folder-scroll-browser.test.js‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,21 +51,30 @@ test('live Worker popup, folder pointer targets, chevrons and bound-child scroll
5151
await t.test('Worker creation preserves the Driver pane; popup and LED follow real WebSocket output', async function () {
5252
await page.locator('.session-item[data-session-id="' + driverId + '"] .session-item-title').first().click();
5353
await page.waitForFunction(function (id) { var frame = document.querySelector('#split-host iframe[data-session-id="' + id + '"]'); return frame && frame.clayPane && frame.clayPane.identity().ready; }, driverId);
54+
await (await (await page.locator('#split-host iframe[data-session-id="' + driverId + '"]').elementHandle()).contentFrame()).locator('#input').focus();
5455
await page.evaluate(async function (group) { (await import('/p/reuse/modules/app-messages.js')).processMessage({ type: 'pair_session_created', ok: true, group: group }); }, data.groups[0]);
5556
assert.equal(await page.locator('#split-host .split-pane:not(.split-pane-cached)').count(), 1);
5657
var driverFrame = await (await page.locator('#split-host iframe[data-session-id="' + driverId + '"]').elementHandle()).contentFrame();
5758
await driverFrame.evaluate(async function (group) { (await import('/p/reuse/modules/app-messages.js')).processMessage({ type: 'pair_session_created', ok: true, group: group }); }, data.groups[0]);
5859
assert.equal(await driverFrame.locator('#split-host .split-pane').count(), 0, 'creation must not mount nested legacy panes inside the Driver iframe');
5960
var chip = page.locator('.split-view-worker-chip[data-worker-session-id="' + workerId + '"]');
60-
await chip.click(); await page.locator('#split-view-worker-popover .worker-readonly-message').last().waitFor();
61+
await page.locator('#split-view-worker-popover .worker-readonly-message').last().waitFor();
62+
assert.equal(await chip.getAttribute('aria-expanded'), 'true', 'creation automatically selects and opens its Worker pill');
63+
assert.equal(await driverFrame.locator('#input').evaluate(function (input) { return document.activeElement === input; }), true, 'automatic opening keeps Driver composer focus');
6164
assert.equal(await page.locator('#split-view-worker-popover .worker-activity-group').count(), 0, 'fixture starts without tool evidence');
6265
await page.request.post(origin + '/fixture/running?id=' + workerId);
6366
await page.request.post(origin + '/fixture/live?id=' + workerId + '&kind=delta&text=VISIBLE_LIVE_WORKER_ACTION');
6467
await page.waitForFunction(function () { return document.getElementById('split-view-worker-popover').textContent.includes('VISIBLE_LIVE_WORKER_ACTION'); });
6568
assert.equal(await chip.locator('.split-view-worker-dot').getAttribute('data-state'), 'working');
6669
assert.equal(await page.evaluate(function () { return getComputedStyle(document.getElementById('split-host'), '::after').content; }), 'none');
67-
await page.keyboard.press('Escape');
70+
await page.getByRole('button', { name: 'Close Worker conversation', exact: true }).click();
6871
await page.locator('#split-view-worker-popover').waitFor({ state: 'detached' });
72+
await page.evaluate(async function (group) { (await import('/p/reuse/modules/app-messages.js')).processMessage({ type: 'pair_session_created', ok: true, group: group }); }, data.groups[0]);
73+
await page.request.post(origin + '/fixture/live?id=' + workerId + '&kind=delta&text=STILL_CLOSED_AFTER_ACTIVITY');
74+
assert.equal(await page.locator('#split-view-worker-popover').count(), 0, 'dismissed introduction must not reopen on repeated creation or output');
75+
await chip.click();
76+
await page.locator('#split-view-worker-popover .worker-readonly-message').last().waitFor();
77+
await page.keyboard.press('Escape');
6978
});
7079
await t.test('sent message stays visible above the composer through a large streamed response; manual reading is preserved', async function () {
7180
var handle = await page.locator('#split-host iframe[data-session-id="' + driverId + '"]').elementHandle();

0 commit comments

Comments
 (0)