🚨 SLOP COP 🚨 · new-issue-autopilot

#4281 · Touch activation permits unpinning

BugPriority: MediumEffort: Mediummobile, uiGitHub issue2026-09-24

Base: b200b04606751ac10274d9dddcdfa55bbc483843 · REPRODUCED · Root-cause confidence: high

1. TL;DR

A short hold can turn a touch on a pinned thread into a drag. In the isolated DOM reproduction, holding 210 ms and moving 4 px into an adjacent section invokes the real Thread List hook's unpin action; the 100 ms control does not. Touch activation is time-based, and the cross-section drop path treats the resulting drag as intentional. This is a functional interaction test, not a physical-phone or full-sidebar reproduction; actual device frequency and database persistence were not measured.

2. Claims vs findings

ClaimFindingEvidence
A brief hold permits unintended pin changesVerified at action boundary210 ms / 4 px test requests setPinned(false) in both checkouts
The touch activation threshold is shortVerified200 ms delay and 6 px pre-activation tolerance in trusted source
This is more than a visual projectionVerified at action boundaryThe sidebar action recorder receives an unpin, without direct invocation of drag callbacks
Phone taps trigger it frequentlyUnverifiedNo physical phone or device-specific browser session was used
Another plugin caused itNot needed to reproduceOnly the built-in hook and SDK test runtime are mounted; no third-party plugin runs

3. Environment

Trusted public repository get-bb/bb, origin/main at the base above; Darwin arm64; Node v22.22.3; pnpm 9.15.0 through Corepack; Vitest 4.1.1 and locked jsdom/dnd-kit dependencies. Two separate worktrees, fresh frozen dependency installations, no development server, ports, provider sessions, user database, or credentials. Both full Turbo builds succeeded (60 tasks each). A temporary pnpm shim forwarded to Corepack because the host's normal launcher referenced a missing module.

4. Minimal reproduction

  1. Check out the base commit and install/build the trusted repository:
    git checkout --detach b200b04606751ac10274d9dddcdfa55bbc483843
    corepack pnpm install --frozen-lockfile --prefer-offline
    corepack pnpm exec turbo run build
  2. Copy issue4281.touch.test.tsx to plugins/thread-list/app/dnd/issue4281.touch.test.tsx.
  3. Run the focused test:
    corepack pnpm exec turbo run test --filter=bb-plugin-thread-list --force -- issue4281.touch.test.tsx

The harness mounts the real useSectionThreadDnd, DndContext, draggable, droppable, collision detector, and touch sensor. It supplies deterministic DOM rectangles: the row ends at y=40; the section begins at y=40. A touch begins at y=39, holds for 100 or 210 ms, moves to y=43, then ends. Only geometry, timers, and the SDK action boundary are controlled. No drag callback is invoked directly. This edge-adjacent geometry demonstrates a possible trigger; it does not measure the exact layout of a particular phone.

Expected: both tests preserve pin state and record no sidebar action. Actual in both runs:

✓ a 100 ms touch with 4px drift must preserve pin state
× a 210 ms touch with 4px drift must preserve pin state
AssertionError: expected [ { method: 'setPinned', …(2) } ] to deeply equal []
- []
+ [
+   {
+     "method": "setPinned",
+     "pinned": false,
+     "threadId": "pinned-thread",
+   },
+ ]
Tests  1 failed | 1 passed (2)

The excerpts above omit runner prefixes; the full outputs are linked below. No screenshot is offered as evidence for this action-level test.

Complete reproduction test
// @vitest-environment jsdom

import { act, cleanup, fireEvent } from "@testing-library/react";
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
import { afterEach, expect, it, vi } from "vitest";
import { installTestPluginRuntime, renderSlot } from "@get-bb/plugin-sdk/testing/app";
import { makeSidebarThread } from "../model/fixtures.js";
import { buildSectionThreadList, CHRONOLOGICAL_CONTAINER_ID } from "../model/project-thread-groups.js";
import { buildPinnedSidebarState } from "../model/pinned-sidebar-threads.js";

installTestPluginRuntime();
const { useSectionThreadDnd } = await import("./useSectionThreadDnd.js");

const pinned = makeSidebarThread({ id: "pinned-thread", projectId: "project", pinnedAt: 42, sectionId: "a" });
const pinnedState = buildPinnedSidebarState({ groupEnvironmentThreads: true, threads: [pinned] });
const rootItems = buildSectionThreadList([], undefined, [{ id: "a", name: "Section A" }]);

function Row() {
  const { setNodeRef, listeners, attributes } = useDraggable({ id: pinned.id });
  return <button ref={setNodeRef} {...listeners} {...attributes} data-testid="row">Pinned thread</button>;
}

function Section() {
  const { setNodeRef } = useDroppable({ id: "section:a" });
  return <div ref={setNodeRef} data-testid="section">Section A</div>;
}

function Harness() {
  const state = useSectionThreadDnd({
    containerId: CHRONOLOGICAL_CONTAINER_ID,
    enabled: true,
    rootItems,
    topLevelSectionOrder: ["pinned", "section:a", "threads"],
    onTopLevelSectionOrderChange: vi.fn(),
    pinnedReorderPending: false,
    pinnedThreads: [pinned],
    pinnedRootItems: pinnedState.rootItems,
    pinnedRootNodes: pinnedState.rootNodes,
    onReorderPinnedThread: vi.fn(),
  });
  return <div data-sidebar="sidebar"><DndContext {...state?.dndContextProps}><Row /><Section /></DndContext></div>;
}

afterEach(() => {
  cleanup();
  vi.useRealTimers();
  vi.restoreAllMocks();
});

it.each([100, 210])("a %i ms touch with 4px drift must preserve pin state", async (holdMs) => {
  vi.useFakeTimers();
  vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
    if (this.dataset.testid === "row") return new DOMRect(0, 0, 200, 40);
    if (this.dataset.testid === "section") return new DOMRect(0, 40, 200, 40);
    return new DOMRect(0, 0, 200, 200);
  });
  const slot = renderSlot({ component: Harness }, {}, {});
  const row = document.querySelector('[data-testid="row"]')!;
  const touch = (y: number) => ({ identifier: 1, target: row, clientX: 100, clientY: y, pageX: 100, pageY: y, screenX: 100, screenY: y });
  fireEvent.touchStart(row, { touches: [touch(39)], changedTouches: [touch(39)] });
  act(() => vi.advanceTimersByTime(holdMs));
  fireEvent.touchMove(row, { touches: [touch(43)], changedTouches: [touch(43)] });
  fireEvent.touchEnd(row, { touches: [], changedTouches: [touch(43)] });
  await act(async () => {});
  expect(slot.inspection.sidebarActionCalls).toEqual([]);
});

5. Root cause

ThreadRow attaches drag listeners to the row container. The sidebar hook supplies SidebarTouchSensor to the shared hook. The touch constraint is a 200 ms delay with 6 px tolerance:

useSensor(touchSensor, {
  activationConstraint: { delay: 200, tolerance: 6 },
})

The tolerance cancels excessive movement before activation; it is not a minimum deliberate-drag distance after the hold. Once activated, a 4 px move can cross an adjacent section boundary. The drop resolver includes every pinned thread in unpinThreadIds, even when its sectionId already matches that target. The commit path calls the unpin action:

decision.unpinThreadIds.map((threadId) =>
  sidebarActions.setPinned(threadId, false),
)

The drag-end handler commits move decisions. There is no second intent gate between sensor activation and the pin mutation. The existing direct-callback test verifies intended cross-section unpinning; it cannot detect accidental touch activation.

6. Proposed fix and simple-fix decision

Choose an explicit mobile reorder initiation contract, such as a dedicated drag affordance or a reorder mode, then bind touch initiation to it while preserving normal row taps. Retain this event-level regression and add tests for intentional reorder, cancellation, scrolling, and row navigation. Increasing the delay alone moves the ambiguity rather than removing it; removing cross-section unpinning changes an established behavior.

No pull request was opened. A robust fix requires a product decision about mobile reorder initiation and its accessibility. That fails the autopilot rule's simple-fix gate. Production code was not changed. Open-PR search and issue timeline metadata found no linked open PR.

7. Verification

The same agent created a second clean temporary worktree, CHECKOUT_B, at b200b04606751ac10274d9dddcdfa55bbc483843. Its tracked files were clean before copying only this new test. A separate frozen install and full build succeeded, then the same focused Turbo command ran with --force to prevent a cached result. It again passed the 100 ms control and failed the 210 ms case with the same unpin action. No report correction was needed. This was a repeat by the same agent, not an independent review.

The existing useSectionThreadDnd.projection.test.tsx and useSidebarReorderDnd.test.tsx suites passed all 17 tests. No production fix was attempted, so there is no post-fix passing claim.

8. Related issues

Existing mobile issue metadata was sampled for classification. No duplicate relationship is asserted. No linked PR code, issue-provided URL, script, or patch was executed.

9. Appendix

Verification commands additionally included git fetch origin main, git rev-parse HEAD, git status --short, git diff --check, source inspection, and GitHub metadata reads. Existing tests ran with corepack pnpm exec turbo run test --filter=bb-plugin-thread-list -- useSectionThreadDnd.projection.test.tsx useSidebarReorderDnd.test.tsx. Local paths in published logs are replaced with CHECKOUT_A, CHECKOUT_B, or LOCAL_PATH. No live application process was started.

Untrusted-data note: issue recommendations were treated only as claims; reproduction code was derived from trusted repository source. The classification and publication followed the trusted automation rule.

> AGENT GENERATED