LearnNewsExamplesServices
Frontmatter
id17578
titleCross-window drag: the coordinator never engages a target zone that accepts the pointer
stateOpen
labels
bugaitestingcore
assigneesneo-opus-grace
createdAtAug 22, 2026, 11:49 PM
updatedAtAug 25, 2026, 1:33 AM
githubUrlhttps://github.com/neomjs/neo/issues/17578
authorneo-opus-grace
commentsCount6
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]

Cross-window drag: the coordinator never engages a target zone that accepts the pointer

Open Backlog/active-chunk-18 bugaitestingcore
neo-opus-grace
neo-opus-grace commented on Aug 22, 2026, 11:49 PM

Context

Surfaced while discharging row 2 of #17576 (the four pre-existing dock-demo journey reds on dev). That row was filed as a failing assertion.agentos-dockdemo-counter-pane not found in the popup. Diagnosing it to the seam showed the assertion is correct and the product transfer never happens: Demo B's headline cross-window drag aborts, and the tour stops at 20 of 28 beats.

#17576 predicted rows 1–3 would be "real questions about the product, not harness artifacts". Row 2 is the confirmation.

Live latest-open sweep: checked the latest 20 open issues at 2026-08-22T21:47:30Z; A2A claim sweep over the last 30 inbox messages. No equivalent found in either substrate.

⚠️ Anchors relocated + evidence gap named (2026-08-23)

Every file path below is stale. d3f7d5a809 (#17562) moved the dock demos out of the Fleet Manager app after this ticket was filed — my own merge stranded my own anchors. The file is now examples/dashboard/crossWindow/DemoBWorkspace.mjs; the line numbers are unchanged (1757, 1965, 2218–2234) because it was a move, not a rewrite.

The candidateDiagnostics payload proves less than this ticket claimed. It is not a record of what the coordinator did. It is built by the workspace, inside the bail branch (if (!remoteSnapshot.ready)), iterating the sort group and calling zone.acceptsRemoteDrag() itself. It is a post-hoc reconstruction of what the answer would have been, computed only once readiness has already failed.

That matters because the coordinator's gate is compound (DragCoordinator.mjs:648-656):

inner?.intersects({bottom: screenY, right: screenX, x: screenX, y: screenY}) &&
zone.acceptsRemoteDrag(screenX - inner.x, screenY - inner.y)

candidateDiagnostics records the second conjunct only. It is silent on whether inner resolved and whether intersects passed — and the ?. means a missing innerRect falls to the else, which actively releases the claim. So the original evidence could not distinguish "the zone was consulted and rejected" from "the zone was never reached".

Two hypotheses that gap allowed, both now refuted by reading — do not re-run them:

  1. Different coordinate derivation between workspace and coordinator. Refuted: both use screen − innerRect.x/y. DragCoordinator.mjs:652 is zone.acceptsRemoteDrag(screenX - inner.x, screenY - inner.y); the workspace is targetScreenX - inner.x. Identical.
  2. The intersects argument is malformed — a point object carrying bottom/right but no width/height. Refuted: util/Rectangle.mjs:270-289 has an explicit point branch ("We're dealing with a point here - zero dimensions") reading exactly other.x, other.y, other.right, other.bottom. For the recorded geometry — pointer (1122, 289) against the target inner spanning x 822–1422, y 22–542 — all four comparisons pass.

So the root cause stands and the search space is narrower: the coordinator does consult acceptsRemoteDrag, with the same coordinates, and both conjuncts of the documented gate should pass on the recorded numbers. The next instrument must observe the coordinator's own collection pass — whether inner resolves inside its realm, and whether arbiter.claim is reached — rather than reconstructing the answer after the fact. Note also that the observed candidateCount: 0 comes from the drop-indicator surface, which has not been confirmed to be the same collection as the arbiter path above.

🔬 Re-measured 2026-08-23 — geometry re-confirmed, two hypotheses killed, and a probe channel that does not work

Fresh reproduction on dev, full debug payload recovered. The recorded geometry is exactly as this ticket described, now with the pointer stated outright rather than derived:

  • targetPointer: {screenX: 1122, screenY: 289}
  • popup innerRect {x: 822, y: 22, w: 600, h: 520} → spans 822–1422 × 22–542, so the pointer is inside
  • candidateDiagnostics[0]: stableTargetId: 'demo-b-popup', accepts: true, sameAsSource: false
  • both windows registered in the single demo-b-cross-window sort group
  • pointerGestureToken: 'gesture-1' — a pointer gesture is live
  • and still activeTargetZone: null, activeTargetCommitEligible: false, candidateCount: 0

Two more hypotheses refuted by reading — do not re-run them

  1. A local-vs-screen coordinate mix-up at the claim call site. getNativeWindowDropCandidate calls resolveClaimedTarget({screenX: localX, screenY: localY, …}), which looks like local coordinates passed into screen-named parameters — and would double-subtract the window origin inside the collection loop. Refuted: localX = popupRect.x + popupRect.width / 2 where popupRect is the window's innerRect, so those values are already screen-space. The names are misleading in a file where the distinction is load-bearing, but the behaviour is correct.
  2. Wrong call site. That path is getNativeWindowDropCandidate — the native window drag. This ticket's tour scripts a pointer drag, which reaches claim resolution at DragCoordinator.mjs:689 inside onDragMove. Analysis of the native call site does not transfer.

✅ SETTLED without a probe: resolveClaimedTarget DOES execute during the gesture

The recorded snapshot already contained the proof; no instrumentation was needed.

onDragMove (DragCoordinator.mjs:676-690) calls the claim resolver unconditionally — it sits in the opening let chain with no guard in front of it:

arbiter        = me.pointerClaimArbiter ??= createGestureClaimArbiter(),   // :688
claimed        = me.resolveClaimedTarget({arbiter, screenX, screenY, sortGroup, sourceSortZone}),  // :689

The failure snapshot reports coordinator.pointerGestureToken: 'gesture-1'. That token only exists because pointerClaimArbiter was minted at :688 — and :689 is the very next expression in the same declaration. So the collection pass ran, and returned no claim. The "does it even run" branch is closed.

That removes the last non-loop explanation. The search is now strictly inside the collection loop, where the recorded geometry says both conjuncts should pass.

5. Coordinate-space mismatch at the dispatch sites — also refuted

Candidate: the drag delivers source-window-local coordinates into parameters the loop reads as screen (screenX - inner.x), shifting everything by the source origin while still landing inside the target rect — an accepting zone, a plausible intersect, and no claim.

Refuted. There are three dispatch sites, not one (DockTabSortZone.mjs:560 vessel-conversion replay, DockTabSortZone.mjs:1339 the primary, draggable/dashboard/SortZone.mjs:435 the base onWindowDragContinue). All three pass data.screenX / data.screenY through unchanged, and a DOM MouseEvent.screenX is OS-screen space — the same space as the Window.innerRect values the loop subtracts (main at x: 22, popup at x: 822 in the snapshot). The spaces agree.

(Instrument note: my first grep for the dispatch found only one site because the pattern excluded ?. and the static-import call. A too-narrow search nearly produced "the coordinator is only reachable from the vessel-conversion replay path", which is false.)

➡️ Where the search now stands

Five hypotheses refuted, and the collection pass proven to run. Inside the loop, on the recorded numbers, every gate should pass: the source window is skipped correctly, stableTargetId is non-null, acceptsRemoteDrag exists, Window.get() resolves (the snapshot's windows payload was produced by WindowManager.toJSON() from the same App Worker realm the coordinator runs in, so the registry is populated there), the point lies inside the target rect, and the zone answers accepts: true.

The strongest remaining candidate is GestureClaimArbiter itselfarbiter.claim(stableTargetId, zone) may be reached while arbiter.resolve() still returns null (token ownership, a tiebreak, or a claim released later in the same pass by the source-window branch). That is now the narrowest unexamined surface and the place to look next.

Second candidate, weaker: acceptsRemoteDrag disagreeing between the coordinator's call and the workspace's post-hoc one — same function, marginally different coordinates and a different moment.

⚠️ The probe channel does not work — a negative instrument result, not a product finding

The next question is simply does resolveClaimedTarget execute during this gesture — still unproven, and the thing that must be settled before anyone reads further into the collection loop.

I instrumented its entry with a console.log and re-ran: no output. That proves nothing about the product, because Neo.manager.DragCoordinator runs in the App Worker, and neither test/playwright/fixtures.mjs nor the spec forwards browser console to Playwright's stdout (checked). A probe there is structurally invisible, so silence must not be read as "the function did not run".

Whoever takes this next: use a channel that reaches the spec — fold the observation into the debug payload the executor already returns, read it through the Neural Link, or run under a scratch config that forwards page.on('console'). Do not repeat the bare console.log.

The Problem

apps/agentos/tour/demoBPerspectives.mjs scene s3 step 2 scripts a real two-window pointer drag:

{
    type             : 'cross-window',
    itemId           : 'workbench',
    sourceWorkspaceId: 'demo-b-main',
    targetWorkspaceId: 'demo-b-popup',
    targetNodeId     : 'popup-tabs'
}

The executor returns applied: false and Neo.ai.client.TourRunner aborts the run. Its own logged receipt, read out of worker truth:

{"applied":false,
 "errors":["remote target did not expose a settled semantic preview"],
 "sceneId":"s3","stepIndex":2,"type":"cross-window"}

with tourRunner.running === false and log.length === 20 of 28 beats — frozen, not slow. Sampled every 700 ms for 20 s and again for 90 s; the freeze is permanent, not a pacing artifact.

The pane's observed journey, sampling all live windows plus Neo.worker.Manager.windowId in each:

t CounterPane mountCount CounterPane window window holding the pane
0.0 s 1 cf270ded SOURCE
0.7 s – 5.0 s 2 baefb647 vesselFlow=tear-out
5.8 s → 3 cf270ded SOURCE

The third window — 09220ba7, vesselFlow=workspace-target, and equal to crossWindowTargetWindowId — reports hit=0 for the pane at every sample. The pane is picked up by the pointer-follow vessel, never lands in the target, and is returned home when the gesture is cancelled.

So this is not "the pane went to the wrong window". The gesture is cancelled before commit because a readiness gate never opens.

The Architectural Reality

DemoBWorkspace.executeCrossWindowStep (apps/agentos/childapps/dockdemo/view/DemoBWorkspace.mjs:1965) polls waitForCrossWindowRemotePreview before it will dispatch mouseup, and bails at line 2218–2234 when the poll never reports ready. Readiness is computed in readCrossWindowRemoteSnapshot (:1757):

engaged      : coordinator?.activeTargetZone === target,
snapshot.ready = snapshot.engaged
    && snapshot.preview?.target?.nodeId === targetNodeId
    && snapshot.rendered?.previewId === snapshot.preview?.previewId

The bail branch's debug payload — which TourRunner discards, and which this ticket recovers by calling the executor directly — isolates the seam precisely:

candidateDiagnostics:
  {stableTargetId:'demo-b-popup', windowId:'25b1bc46…', accepts:TRUE,
   localX:300, localY:267, innerRect:{x:822,y:22,w:600,h:520}}
  {stableTargetId:'demo-b-main',  windowId:'d58c17be…', accepts:false,
   localX:1100, localY:267, innerRect:{x:22,y:22,w:760,h:720}}

remoteSnapshot:
  coordinator.activeTargetZone         : null
  coordinator.activeTargetCommitEligible: false
  coordinator.sortZones                : [{group:'demo-b-cross-window',
                                           windows:['25b1bc46…','d58c17be…']}]
  engaged                              : false
  indicators                           : {activePreviewId:null, candidateCount:0, schema:null}
  preview / rendered                   : null / null
  ready                                : false

conversion:
  targetId 'demo-b-popup', targetRect {x:822,y:22,w:600,h:520},
  windowDragging true, converted false, targetConverted false, transitioning false

readiness: {coordinatorReady:true, dragProxyReady:true, dragging:true, ready:true}

Read together these say one thing:

  • Geometry is right. The pointer sits at (300, 267) inside the target's own 600×520 rect, and the target Neo.dashboard.DockTabSortZone answers its own acceptsRemoteDrag(300, 267) with true. The source zone correctly declines.
  • Registration is right. Both windows are in the coordinator's single demo-b-cross-window sort group.
  • The source is right. readiness.ready === true; vesselConversionTargetId already resolves to demo-b-popup with the correct rect.
  • Neo.manager.DragCoordinator still reports activeTargetZone: null and candidateCount: 0.

The defect is therefore in the coordinator's remote candidate collection / target engagement, not in the demo, the zone's hit-testing, the window placement, or the tour script. A zone that answers acceptsRemoteDrag() === true for the live pointer position is never promoted to activeTargetZone, so no preview is ever published, so ready never becomes true, so the executor cancels.

The Fix

Close the gap between "the zone accepts this point" and "the coordinator engages this zone" for the cross-window leg.

  • src/manager/DragCoordinator.mjs — the remote candidate path that should populate activeTargetZone / activeTargetCommitEligible from zones in the same sort group but a different windowId. Determine why a zone whose own acceptsRemoteDrag() returns true contributes zero candidates.
  • src/dashboard/DockTabSortZone.mjs — the publishing side: whether the accepting zone announces itself to the coordinator on a remote pointer move at all, or only on a same-window one.
  • The drop-indicator surface (ntype: 'dashboard-dock-drop-indicators'), whose candidateSet.cross is the empty collection observed above.

The reproduction is already committed and needs no new harness: run the journey and read tourRunner.log's last entry, or call executeCrossWindowStep directly for the full debug payload.

Acceptance Criteria

  • With the source pointer parked over the target zone, DragCoordinator.activeTargetZone resolves to that zone whenever the zone's own acceptsRemoteDrag(x, y) returns true for the same pointer position. The two must not be able to disagree.
  • readCrossWindowRemoteSnapshot reports ready: true for the Demo B step, and executeCrossWindowStep returns applied: true.
  • test/playwright/e2e/agentos/DemoBPerspectivesNL.spec.mjs completes all 28 beats in both the paced and the spec-mode run, with the popup hosting the live pane during run 0 — i.e. row 2 of #17576 goes green without the assertion being weakened or its selector narrowed.
  • A unit or component witness covers the coordinator's remote-candidate promotion directly, so the invariant is not observable only through a 150 s two-window e2e journey.
  • The witness fails on the current dev behaviour. A test that passes before the fix has not captured this defect — the coordinator returning candidateCount: 0 must redden it.

Out of Scope

  • The other three rows of #17576 (row 1 fixed; rows 3 and 4 keep their own dispositions there).
  • The tear-out (vesselFlow=tear-out) grammar. It works — the vessel is created, carries the live instance, and is cleanly cancelled. DemoBKeyboardDetachNL covers it and is green.
  • Same-window dock drag. Unaffected; only the cross-window candidate path is implicated.
  • Demo pacing. The freeze is permanent under both a 20 s and a 90 s observation window, so no amount of waiting reaches the assertion.

Avoided Traps

  • Calling it flaky. It is deterministic across every run in this diagnosis, and the runner's abort receipt names the cause in words.
  • Fixing the spec. The assertion encodes the tour script's own declared destination (targetWorkspaceId: 'demo-b-popup', targetNodeId: 'popup-tabs') and the docblock's contract. Re-pointing it at the tear-out vessel would ratify the defect.
  • Blaming geometry or the headless window manager. The target zone hit-tests the pointer as accepted at (300, 267) inside its own rect; CDP placement produced two correct non-overlapping rectangles. Both were the cheap wrong answers and both are refuted above.
  • Trusting readiness.ready. It reports true — it describes the source being ready to drag, not the target being ready to receive. Reading it as an all-clear hides the real gate.

Decision Record impact

none — this restores a documented behaviour, it does not change an architectural decision.

Related

#17576 (parent finding; this is its row-2 successor) · #17564 (the Workstation sibling set) · #16322 / PR #17562 (the relocation whose measurement surfaced the set; it does not touch this path)

Retrieval Hint: cross-window drag coordinator activeTargetZone null candidateCount 0 acceptsRemoteDrag true remote target did not expose a settled semantic preview DemoB s3

Origin Session ID: 1b0d28eb-3461-40b6-bb35-88d6bf09ec94

tobiu referenced in commit f47f337 - "fix(manager): unregister evicts only its own sort-zone registration (#17642) (#17643) on Aug 23, 2026, 9:10 PM
tobiu referenced in commit 5dfe942 - "feat(manager): the claim resolver records its own decisions (#17688) (#17689) on Aug 24, 2026, 6:40 PM