Skip to content

feat: add selector-targeted drag gestures - #1567

Draft
thiagobrez wants to merge 2 commits into
mainfrom
feat/selector-drag-gesture
Draft

feat: add selector-targeted drag gestures#1567
thiagobrez wants to merge 2 commits into
mainfrom
feat/selector-drag-gesture

Conversation

@thiagobrez

Copy link
Copy Markdown
Contributor

Summary

Adds a generic selector/ref-targeted drag gesture across the CLI, Node client, MCP, recording, and replay surfaces.

  • Resolves both source and destination before dispatch, then lowers their centers to one uninterrupted pointer plan: source hold → timed movement → optional destination hold → release.
  • Accepts selectors or snapshot refs at either endpoint and reports each endpoint's selector chain and resolution disclosure.
  • Saves portable selector chains instead of session-local refs; source identity is recorded as target-v1 evidence and replay-verified before dispatch.
  • Applies ADR 0014 ref admission to both endpoints and expires the frame at the normal mutation seam.
  • Removes the throwaway prototype assets entirely; this branch is a single generic commit with no fixture- or downstream-project-specific files/history.

Public API

CLI:

agent-device gesture drag 'id="drag-source"' 'id="drop-target"'
agent-device gesture drag @e4~s12 'label="Archive"' 700 600 200

Node:

await client.interactions.drag(
  'id="drag-source"',
  'id="drop-target"',
  { sourceHoldMs: 700, moveMs: 600, destinationHoldMs: 200 },
);

MCP:

{
  "kind": "drag",
  "source": "id=\"drag-source\"",
  "destination": "id=\"drop-target\"",
  "sourceHoldMs": 700,
  "moveMs": 600,
  "destinationHoldMs": 200
}

Defaults are 800 ms source hold, 500 ms movement, and 0 ms destination hold. The combined plan is capped at 10 seconds.

Validation

  • pnpm check:affected --run
    • 368 test files passed
    • 3,444 tests passed
    • format, lint, typecheck, layering, fallow, and build passed
  • Focused gesture/projection/recording suite: 12 files, 172 tests passed.
  • Android artifact freshness: pnpm build:android completed before device validation.
  • iOS simulator: the fresh built CLI performed a selector-targeted reorder and returned disclosures plus portable chains for both endpoints.
  • Android emulator: the fresh bundled helper executed the continuous 1,500 ms plan as 42 injected events and returned both endpoint disclosures.
  • Existing native-iOS, fallback-iOS, and fallback-Android prototype runs all produced the expected app-state transitions.

Load-bearing red proof:

  • With endpoint selector materialization removed, the daemon regression recorded @e2~s42 instead of a portable selector.
  • With ref admission removed, a second drag incorrectly dispatched after the first expired the frame.
  • The focused regression file failed 2/2 under those mutations, then passed 2/2 after restoring production behavior.

Recording and replay notes

The recording format supports one target-v1 annotation per action. Drag uses the source as that action identity and verifies it before replay dispatch; the destination is still materialized to a durable selector chain and resolved before any device gesture. Strict publication refuses either endpoint if it remains a bare ref.

Video evidence

The native-iOS, fallback-iOS, and fallback-Android recordings were intentionally removed with the throwaway prototype directory, so the branch and PR history stay generic. The files are available for manual attachment to this description; GitHub CLI cannot upload user-attachment blobs.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-1567/

Built to branch gh-pages at 2026-08-03 13:44 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.93 MB 1.94 MB +4.9 kB
JS gzip 619.6 kB 620.8 kB +1.2 kB
npm tarball 739.3 kB 740.6 kB +1.3 kB
npm unpacked 2.59 MB 2.60 MB +5.2 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 18.6 ms 21.1 ms +2.5 ms
CLI --help 43.2 ms 52.2 ms +9.0 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/screenshot-geometry.js +24.4 kB +7.8 kB
dist/src/runtime.js +1.7 kB +445 B
dist/src/registry.js +616 B +177 B
dist/src/viewport-dimension.js -209 B -100 B
dist/src/selector-runtime.js -267 B -91 B

@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member

Review: direction is right, two defects to fix, and one encapsulation theme

Direction: yes. A target-authored drag is the correct primitive to add — it belongs at the interaction layer next to press/click, not as a coordinate recipe callers assemble themselves. Three choices in particular are right:

  • Resolving both endpoints to a durable selector chain and recording those instead of session-local refs. Bare-ref recordings are the thing that makes replay brittle, and assertNoUnresolvedDragEndpoint refusing publication is the correct hard stop.
  • Applying ADR 0014 ref admission to both endpoints, then expiring the frame at the normal mutation seam.
  • Threading the surface through RAW_COMMAND_DESCRIPTORS so CLI, MCP, and docs derive from the descriptor rather than three hand-maintained copies.

Gates I ran locally on 3b636f0: typecheck, lint, and check:layering all pass (layering reports 993 files clean, R11 intact), and the focused suite is green — 5 files / 39 tests. The load-bearing red proof in the description is the right kind of evidence.

Below: two defects I reproduced, then the encapsulation review you asked for.


1. Recording round-trip corrupts partially-specified drag timings

gesturePayloadToPositionals encodes drag through compact() (gesture-normalization.ts:313), and compact drops undefined anywhere in the list, not just trailing:

function compact(values: Array<string | number | undefined>): string[] {
  return values.filter((value): value is string | number => value !== undefined).map(String);
}

Drag is the first gesture with three independent optional positionals, so a hole in the middle shifts everything after it into the wrong slot. Reproduced against this branch:

encoded -> ["drag","id=\"a\"","id=\"b\"","700"]
decoded -> { kind:'drag', source:'id="a"', destination:'id="b"', sourceHoldMs:700 }

{ moveMs: 700 } round-trips as { sourceHoldMs: 700 }. Reachable from MCP ({"kind":"drag","source":…,"destination":…,"moveMs":600}) and from the Node client (client.interactions.drag(src, dst, { moveMs: 600 })) — both legal per the contract, since all three timings are optional. CLI positional syntax can't express the hole, which is why the existing round-trip test misses it: it only covers the all-fields-present payload.

The consequence is the bad kind: a recorded script silently replays a different gesture. For the long-press-drag reorder this feature exists to drive, moving 600ms out of moveMs and into sourceHoldMs changes whether the drag activates at all.

Fix is either positional placeholders for drag, or encode the timings as a trailing triple that is all-or-nothing. Worth a round-trip test over each of the 8 present/absent combinations.

2. gesture drag reports itself as gesture pan when unsupported

capabilities.ts:145 fabricates a zero-delta pan to reuse the existing checks:

const capabilityInput: GestureSemanticInput =
  input.intent === 'drag'
    ? { intent: 'pan', origin: { x: 0, y: 0 }, delta: { x: 0, y: 0 } }
    : input;

That synthetic payload is then what builds the message and the structured detail. Actual output on this branch:

{"platform":"web"}                          -> "gesture pan is not supported on web"          details.gesture="pan"
{"platform":"ios","appleOs":"visionos"}     -> "gesture pan is not supported on visionos"     details.gesture="pan"
{"platform":"ios","appleOs":"watchos"}      -> "gesture pan is not supported on watchos"      details.gesture="pan"

A user who typed gesture drag is told gesture pan is unsupported, and details.gesture — which agents key on — carries the wrong command. Given ADR 0010 and the error-system work, this one should not ship as-is. Drag wants its own branch (or an intent→capability-key map) so the message names the command the caller actually ran.


Module encapsulation

The surface-level encapsulation is good: contracts owns validation and the codec, the descriptor registry owns the public surface, assertRefMutationAdmitted was extracted as a proper shared throwing form rather than copy-pasted. Layering passes.

The problem is one level down. Drag is threaded through as an exception at every layer instead of being admitted into the shared gesture model, and it shows up as the same shape repeated:

Four modules independently hard-code drag's positional layout. There is no shared accessor, so the encoding in packages/contracts is now load-bearing for three consumers that reach into it by index:

Module Coupling
packages/contracts/src/gesture-normalization.ts:264,313 owns the encoding
src/daemon/handlers/interaction-gesture.ts:237-238 writes positionals[1], positionals[2]
src/daemon/session-script-writer.ts:345 reads positionals.slice(1, 3)
src/daemon/handlers/session-replay-target-token.ts:8 reads positionals[1]

Reorder drag's positionals and three of these break silently — and defect 1 above is exactly what a hole in that layout already does. The daemon rewrite in particular would read better as a re-encode than an index patch:

gesturePayloadToPositionals({
  ...input,
  source: recording.sourceSelector ?? input.source,
  destination: recording.destinationSelector ?? input.destination,
})

…which keeps the layout knowledge in the one module that owns it, and stops being wrong the moment placeholders land for defect 1.

The type model doesn't absorb drag, so each layer widens or casts around it:

  • resolveExecutionProfile returns 'hold-drag' (interaction-gesture.ts:198), but GestureExecutionProfile is 'endpoint-hold' | 'timed-pan' and buildDragGesturePlan stamps the plan 'timed-pan'. So the response advertises a profile that is not in the union and is not what executed. The declared return type is string | undefined, so nothing catches it. Either admit 'hold-drag' into the union and carry it onto the plan, or rename the response field so it isn't read as the plan's profile.
  • kind: GestureIntent | 'drag' — an inline widening rather than the exported GestureCommandInput model.
  • capabilities.ts types its parameter GestureSemanticInput | { intent: 'drag' } even though GestureCommandInput is exported from contracts for exactly this. Two spellings of one concept.
  • Two casts where a discriminated narrow would do: options.gesture as GestureSemanticInput (gesture-command.ts:85) and resolved as ResolvedInteractionTarget & { point: Point } (line 169). The first is a direct consequence of narrowing via the separate resolvedDrag variable instead of branching on options.gesture.intent — which is also why the same block needs dragGesture?.sourceHoldMs on a value that cannot be undefined there.

None of these is individually serious. Together they mean the next gesture that carries targets repeats all of it. Branching once on the discriminant, and letting GestureCommandInput be the single spelling, removes most of the casts and the fake-pan shim at the same time.


Smaller notes

  • Duration sum is validated too late. Each timing is capped at 10s in readGesturePayload, but the total is only checked inside buildDragGesturePlan — after captureGestureViewport and both target resolutions. gesture drag src dst 10000 10000 10000 pays a snapshot and two resolutions before failing INVALID_ARGS. The sum check belongs in contracts next to the per-field ones.
  • readNonEmptyString validates value.trim().length > 0 but returns the untrimmed string, so ' id="x" ' reaches the resolver with padding.
  • sourceHoldMs has min: 1 while destinationHoldMs has min: 0. Presumably deliberate (you must hold to activate; you needn't hold to release) but it's undocumented and will read as a typo.
  • prepareDragTarget returns { target } and both call sites immediately unwrap .target.
  • No corpus coverage. grep finds gesture drag nowhere under examples/ or test/ — only in website/docs. Unit coverage is solid, but the repo's test-app:replay:* corpora are how gestures usually earn their keep, and removing the prototype assets left the new command with no replay fixture. A small drag.ad against a test-app reorder target would also give the timing semantics somewhere to regress.

One thing I could not verify

Drag lowers to executionProfile: 'timed-pan', so it shares the runner's .sampled path with gesture pan. While testing something unrelated on iOS earlier today I saw gesture pan wall-time not scale with its requested durationMs (400 / 1200 / 3000ms all completing in roughly the same time). I could not confirm that: every one of those trials used a pan geometry that was inert, so the fast return may just be an early-out, and a re-test at a moving geometry was blocked by another daemon owning the simulator. Flagging it only because this feature's whole value rests on an 800ms activation hold actually lasting 800ms on device. If it hasn't been checked directly, it's worth asserting the observed contact duration once on each platform rather than inferring it from the plan.

@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member

Reviewed exact head 3b636f0; not merge-ready.

  1. P1 — replay verifies only the source endpoint. Drag records/resolves two element targets, but the replay token/guard is built only from positional 1 and resolveDragTarget explicitly disables expectedResolvedTarget for destination. A destination selector can rebind to a different drop target and still execute without REPLAY_DIVERGENCE. ADR 0012 requires identity evidence/verification for every element resolution. Introduce a versioned multi-target evidence/guard shape and verify both endpoints before pointer-down; add a shifted-destination counterfactual.

  2. P1 — recording corrupts sparse timing options. gesturePayloadToPositionals passes the three independently optional drag timings through compact, dropping interior undefineds. For example Node/MCP { moveMs: 600 } records gesture drag <source> <destination> 600, which replays as sourceHoldMs=600 and default movement instead of default source hold plus a 600 ms movement. Preserve positional slots or serialize fully materialized canonical timings, and add hole-case round trips.

  3. P1 — the new element-targeting dispatch path is absent from ADR 0011’s guarantee matrix. The matrix still classifies only existing runtime selector/ref touch commands and coordinate paths; drag’s dual endpoint disambiguation, occlusion/offscreen/non-hittable behavior, identity, disclosures, response construction, and errors are not declared or contract-scenario-gated. Add an honest dual-endpoint drag path/coverage. Simply adding gesture to existing lists would be dishonest while drag builds a bespoke response rather than using their declared response builder.

CLI/Node/MCP/daemon routing, ref admission/expiry, portable ref rewriting, duration planning, CI, and the reported device runs otherwise look sound. The device evidence is described but not attached/reproducible, so that remains a readiness residual.

@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member

Deep code-quality audit (structural)

Follow-up to my earlier comment, which covered two reproduced defects and the positional-layout coupling. This pass is purely about structure: is this the simplest shape this feature can take? I don't think it is, and I think there are two code-judo moves that delete most of the new complexity rather than rearrange it.

Not approving on structure yet. Behavior looks right and the gates are green — that isn't the bar here.

File-size rule: clean. Nothing crosses 1k because of this PR. registry.ts 1515→1516 and client.test.ts 1381→1412 were already over and grew trivially. No objection there.


1. Drag logic is in the wrong module, and that's what forces the casts

src/commands/interaction/runtime/gesture-command.ts was a 101-line coordinate-only dispatcher. It's now 254 lines — 2.5× — because it absorbed target resolution, disclosure shaping, recording-evidence extraction, and selector-chain joining.

That work already has a canonical home in the same directory. runtime/gestures.ts is where target-resolving interaction commands live — focusCommand, longPressCommand, scrollCommand — and every one of them follows exactly the shape drag needs:

const resolved = await resolveInteractionTarget(runtime, options, {
  action, requireInteractive, promoteToHittableAncestor,
  expectedResolvedTarget: options.expectedResolvedTarget,
});
const point = requireResolvedPoint(resolved);

return { ...resolved,};   // disclosure / selectorChain / evidence propagate by spread

The new code reimplements that spine instead of using it:

canonical, in gestures.ts reimplemented in gesture-command.ts
requireResolvedPoint (:264) — returns a narrowed Point inline if (!resolved.point) throw + as ResolvedInteractionTarget & { point: Point } (:168-169)
return { ...resolved, … } propagates disclosure/evidence bespoke dragTargetDisclosure, dragTargetDisclosures, recordedDragTarget, selectorExpression

Note what that first row means: the cast at :169 exists only because the canonical helper wasn't used. requireResolvedPoint already does the narrowing. That's not a nit about a cast — it's a cast that is a symptom of the placement being wrong.

The judo move: put dragCommand in gestures.ts next to longPressCommand, and let gesture-command.ts go back to being coordinate-only. That single move deletes:

  • the as GestureSemanticInput cast at gesture-command.ts:85 — the coordinate dispatcher stops receiving a union it has to narrow away
  • the as ResolvedInteractionTarget & { point: Point } cast at :169 — reuse requireResolvedPoint
  • dragGesture?.sourceHoldMs optional-chaining on a value that cannot be undefined in that branch
  • expectedResolvedTarget on GestureCommandOptions, which only drag ever reads
  • most of the four bespoke disclosure/recording helpers, in favour of the ...resolved convention

I'll grant the one real asymmetry: drag has two endpoints and the spread convention carries one. So the destination needs something bespoke. But the source is the recorded, replay-verified identity — it can use the canonical path as-is, and only the destination needs a small addition. That is a much smaller delta than the current 153 new lines in the wrong file.

2. buildDragGesturePlan duplicates buildSinglePointerPlan; it should be a decorator

gesture-plan.ts grew 475→560, and ~60 of those lines re-derive what buildSinglePointerPlan (:278) already does — finitePoint ×2, sampleOffsets, interpolatePoint, assertSamplesInViewport, and a byte-identical plan literal. The only genuine difference is a hold sample before the move and optionally one after.

A drag plan is a pan plan with contact holds bracketing it. Expressing that directly:

export function buildDragGesturePlan(input, viewport, platform): SinglePointerGesturePlan {
  const frame = normalizeViewport(viewport);
  const move = buildSinglePointerPlan(
    'pan', input.from, input.to, moveMs, frame, 'timed-pan', gesturePlatformProfile(platform),
  );
  return withContactHolds(move, { leadMs: sourceHoldMs, trailMs: destinationHoldMs });
}

function withContactHolds(plan, { leadMs, trailMs }) {
  const [pointer] = plan.pointers;
  const first = pointer.samples[0], last = pointer.samples.at(-1);
  return {
    ...plan,
    durationMs: leadMs + plan.durationMs + trailMs,
    pointers: [{ ...pointer, samples: [
      { offsetMs: 0, point: first.point },
      ...pointer.samples.map((s) => ({ ...s, offsetMs: s.offsetMs + leadMs })),
      ...(trailMs > 0 ? [{ offsetMs: leadMs + plan.durationMs + trailMs, point: last.point }] : []),
    ]}],
  };
}

~10 lines replacing ~60, no duplicated invariants, and the viewport/finite/sample rules stay owned by one builder. It also drops the .slice(1) special case in the current version: the pan plan's own offset-0 sample naturally becomes the hold-end sample once shifted by leadMs.

Worth noting this combinator is reusable the moment anything else needs "press and hold, then move" — which is the same primitive longPress + drag would want to share.

3. intent === 'drag' is now a branch in eight places across six modules

Counting the special-cases this PR adds: normalizeGestureCommandInput, publicGestureFromPayload (throw), requireGestureSupported, assertSupportedInteractionSurface remap, the plan selection in gestureCommand, the message selection, prepareGestureCommandInput, resolveExecutionProfile — plus the four modules hard-coding the positional layout that I listed in the earlier comment.

Every one of those is "is this the odd one out?" That's the signature of a model that hasn't absorbed the concept. Moves 1 and 2 remove roughly half of them on their own; the rest mostly collapse once GestureCommandInput is the single spelling instead of GestureSemanticInput | { intent: 'drag' } being re-spelled inline in capabilities.ts.

The fake-pan shim is the worst instance and I covered it above — it's also a live bug, since it makes gesture drag report itself as gesture pan.

4. 'hold-drag' isn't real

resolveExecutionProfile returns 'hold-drag', GestureExecutionProfile is 'endpoint-hold' | 'timed-pan', and the plan is stamped 'timed-pan'. Three different answers to one question, and the string | undefined return type means nothing catches it.

Under move 2 this resolves itself: a drag genuinely is timed-pan plus holds, so either report that honestly, or add 'hold-drag' to the union and carry it onto the plan so the runner can see it. What shouldn't survive is a response field advertising a profile that neither the type nor the plan agrees with.


Summary

The feature is the right thing to build and the surface design (portable selector chains, dual-endpoint ref admission, descriptor-driven registration) is good. But the implementation is currently threaded around the existing architecture rather than through it: it lands in the coordinate dispatcher instead of the target-resolving module next door, re-derives two helpers that already exist, and pays for that with casts, a fake capability payload, and an execution-profile string that isn't in its own union.

Suggested order: move dragCommand into gestures.ts (1) → convert the plan builder into the hold decorator (2) → then the remaining intent === 'drag' branches and 'hold-drag' mostly fall out. I'd expect that to land the feature with materially less new code than the current 1,156 lines, and no casts.

Happy to be argued out of any of this if there's a constraint I'm not seeing — particularly on the two-endpoint recording limit, which is the one place I think a bespoke shape is genuinely earned.

@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member

Could this reuse gesture transform instead of adding a new kind?

Worth answering explicitly since it's the obvious "do we need new API at all?" question. I checked, and the answer is no — but it sharpens what should change.

transform is two-fingered. buildTransformPlan returns topology: 'two' with pointers: readonly [PointerTrajectory, PointerTrajectory] (gesture-plan-types.ts:69). A long-press-drag reorder needs one uninterrupted single-pointer contact. Confirmed live against the test-app gesture lab: gesture transform drives the lab's two-pointer target (minPointers={2}, GestureLab.tsx:244) and sets pan changed yes, pinch changed yes, rotate changed yes, while a single-pointer pan moves nothing on that same target. Different gestures, not different spellings of one.

And it has neither a hold nor targetstransform x y dx dy scale degrees [durationMs] is coordinate-authored with no activation hold, which is the part a reorder UI actually keys on.

Nor is a targeted drag expressible by composition today. longpress 'id="src"' followed by gesture pan … releases contact between the two commands. A drag is one contact across resolve → hold → move → release, which is exactly why this can't be assembled from what exists. That justification holds up.

What about folding it into pan? I'd argue against it, as the strongest alternative worth considering. pan is origin + relative delta; drag is absolute source→destination between two resolved targets. Overloading the positionals so gesture pan 200 430 0 -90 and gesture pan 'id="a"' 'id="b"' share one grammar is the same arg-mode ambiguity that already produced the compact() round-trip bug in my first comment — it would make the codec worse, not better.

Where this does land

The new public surface is small and earned: drag is a subcommand of the existing gesture command, not a new top-level command.

What's oversized is the internal pathway added alongside it — the GestureCommandInput union, a parallel buildDragGesturePlan, and intent === 'drag' branches across six modules — when a drag plan is a pan plan with contact holds bracketing it.

So the recommendation stays what it was, just stated more sharply: keep the new gesture kind, delete the new internal pathway. Moves 1 and 2 from the structural comment — dragCommand into gestures.ts reusing the canonical resolve/record spine, and buildDragGesturePlan collapsed into a withContactHolds decorator over buildSinglePointerPlan. Net new concepts: one public gesture kind, zero internal ones.

@thiagobrez

Copy link
Copy Markdown
Contributor Author

Addressed all four review comments in d51af1b2d.

  • Sparse drag timings now serialize as a fully materialized canonical triple; all 8 present/absent combinations round-trip in tests. Input validation also trims targets and rejects an over-budget total before capture/resolution.
  • Unsupported-surface errors preserve the caller's drag intent in both the message and structured details.
  • Drag dispatch now lives in the canonical target-resolving gestures.ts path, uses requireResolvedPoint, and reports the real timed-pan profile. buildDragGesturePlan is a contact-hold decorator over buildSinglePointerPlan. Endpoint positional knowledge is isolated in the contracts codec rather than repeated in daemon consumers.
  • Replay now records a versioned targets-v1 annotation and verifies/guards both source and destination before pointer-down. The new shifted-destination counterfactual proves that a rebound destination refuses without dispatching.
  • ADR 0011 now has an honest target-drag guarantee row with executable contract scenarios; ADR 0012 documents dual-target evidence. Generic iOS/Android test-app replay corpus entries cover the gesture without project-specific labels.
  • Kept gesture drag as the public API: transform is two-pointer and cannot express one uninterrupted target-to-target contact. Internally it reuses the single-pointer pan plan as suggested.

Red proofs were run for sparse timing corruption, fake-pan capability reporting, skipped destination verification, missing ADR matrix coverage, and late total-duration validation. Final local gate: pnpm check:affected --run (all runnable checks passed; 3,521 related tests, 5,144 unit/smoke tests, 5,386 coverage tests, 149 provider-integration tests, plus replay compatibility and Node integration).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants