Files
PaperClipAI/tests/e2e/new-task-picker-touch.spec.ts
DottaandPaperclip 81a52eb740 fix(ui): allow touch scrolling in new-task pickers (#14599)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The new-task composer lets users select an assignee and override its
model.
> - On phones, these selectors open as large sheets outside the task
dialog DOM.
> - The parent dialog's scroll lock cancels touch drags in those sheets.
> - This pull request gives each mobile sheet its own modal scroll
boundary.
> - Users can scroll to an option, select it, and continue editing their
task.

## Linked Issues or Issue Description

**What happened?**

An iPhone Safari user could not drag through the assignee or model list
in the new-task composer. The list stayed at the top. A touch-enabled
Chromium reproduction also showed canceled touchmove events and an
unchanged scroll position.

**Expected behavior**

A finger drag scrolls the list. A tap selects an option. Closing the
picker preserves the task draft and selected values.

**Steps to reproduce**

1. At phone width, open New Task in a company with enough agents to
overflow the picker.
2. Open Assignee and drag upward through the list.
3. Select a Codex agent, open Codex options, choose Custom, and open the
model selector.
4. Repeat the drag with enough models to overflow the available
viewport.

**Paperclip version or commit**

Reproduced on `e5bf9d49a`. The fix is rebased onto `b3eb03fcb`.

**Deployment mode**

Local development in an isolated test instance. The user reported iPhone
Safari. Browser automation uses native Chromium touch input at phone
dimensions; a physical iPhone was not available.

Related: #14250 introduced the large mobile entity picker sheets.
Duplicate search found no existing fix for their touch scroll boundary.

## What Changed

- Use modal Radix popovers for mobile entity sheets. Their lists can
scroll while the background stays locked.
- Suppress opening when Radix restores trigger focus after dismissal.
Escape and outside taps now close the picker without reopening it.
- Add a failing-before/fixed-after regression for a mobile sheet inside
a parent dialog.
- Add a browser test for native touch scrolling in both lists, tap
selection, draft retention, close-button/Escape/outside dismissal, and
desktop mouse/keyboard selection.
- Document the browser test command.

## Verification

- Passed `pnpm -r typecheck`, `pnpm build`, and `pnpm
check:token-gates`.
- Passed 41 focused tests for `InlineEntitySelector` and
`NewIssueDialog`.
- Passed the new browser test against a disposable instance running this
worktree. It uses 22 real fixture agents and a fixed 24-model catalog.
It covers 390×844 and 390×430 phone viewports and a 1280×900 desktop
viewport.
- Inspected the rendered new-task form and both selectors. Selections
returned to the draft with its title intact.
- `pnpm test:run` was attempted and stopped after confirming that local
database suites were skipping because macOS has exhausted its system
semaphore limit (`initdb`: `could not create semaphores: No space left
on device`). It did not complete locally. The complete test matrix
passed in Linux CI, including all 71 tool-gateway tests.
- All 150 browser tests passed in CI, including the new native-touch
regression.
- Greptile reviewed the latest commit at 5/5. Its desktop focus finding
is fixed and the review thread is resolved. All checks on
`2ab22727ca768b4134c9c840bbc8e4c80d7da674` are complete: 53 passed, 2
intentionally skipped Storybook deployment checks, and the Snyk status
passed.

## Risks

Low risk. Mobile selectors now own focus and scroll isolation. This
changes their modal behavior, so the tests cover nested dismissal and
focus return. Desktop selectors remain non-modal. No schema, API, or
dependency changes.

## Model Used

OpenAI Codex (GPT-6), with reasoning, repository inspection, code
execution, and browser testing. The exact backend deployment ID and
context-window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-29 13:37:03 -05:00

123 lines
6.8 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { expect, test, type APIResponse, type Locator, type Page } from "@playwright/test";
async function json(response: APIResponse) {
expect(response.ok(), await response.text()).toBe(true);
return response.json();
}
async function swipeToLastOption(page: Page, picker: Locator) {
const list = picker.locator("[data-mobile-entity-picker-list]");
const last = list.getByRole("button").last();
// visualViewport resize updates React state after the browser viewport changes.
await expect.poll(async () => {
const bounds = (await picker.boundingBox())!;
return bounds.y + bounds.height;
}).toBeLessThanOrEqual(page.viewportSize()!.height);
const session = await page.context().newCDPSession(page);
try {
expect(await list.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
for (let attempt = 0; attempt < 20; attempt += 1) {
const bounds = (await list.boundingBox())!;
const target = (await last.boundingBox())!;
if (target.y >= bounds.y && target.y + target.height <= bounds.y + bounds.height) break;
const before = await list.evaluate((element) => element.scrollTop);
const x = bounds.x + bounds.width / 2;
const y = bounds.y + bounds.height - 20;
const distance = Math.min(180, bounds.height - 40);
await session.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (let step = 1; step <= 12; step += 1) {
await session.send("Input.dispatchTouchEvent", {
type: "touchMove", touchPoints: [{ x, y: y - distance * step / 12 }],
});
// Pace native finger movement across compositor frames.
await page.waitForTimeout(20);
}
await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await expect.poll(() => list.evaluate((element) => element.scrollTop)).toBeGreaterThan(before);
// A drag must not choose a row or dismiss the picker.
await expect(picker).toBeVisible();
}
const bounds = (await list.boundingBox())!;
const target = (await last.boundingBox())!;
expect(target.y).toBeGreaterThanOrEqual(bounds.y);
expect(target.y + target.height).toBeLessThanOrEqual(bounds.y + bounds.height + 1);
} finally {
await session.detach();
}
return last;
}
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
test("new-task assignee and model sheets scroll by touch and retain the selected values", async ({ page, request, browserName }, testInfo) => {
test.skip(browserName !== "chromium", "Native touch drags use Chromium's input protocol.");
const company = await json(await request.post("/api/companies", {
data: { name: `Touch pickers ${randomUUID()}` },
}));
for (let index = 1; index <= 22; index += 1) {
await json(await request.post(`/api/companies/${company.id}/agents`, {
data: {
name: `Touch Agent ${String(index).padStart(2, "0")}`, role: "engineer",
adapterType: "codex_local", adapterConfig: { model: "gpt-6-sol" },
runtimeConfig: { heartbeat: { enabled: false } },
},
}));
}
// Keep the provider catalog deterministic; task UI, agents, and drafts are real.
await page.route(`**/api/companies/${company.id}/adapters/codex_local/models*`, (route) => route.fulfill({
json: Array.from({ length: 24 }, (_, index) => ({
id: `touch-model-${String(index + 1).padStart(2, "0")}`,
label: `Touch Model ${String(index + 1).padStart(2, "0")}`,
})),
}));
await page.goto(`/${company.issuePrefix}/dashboard`);
await page.getByRole("navigation", { name: "Mobile navigation" }).getByRole("button", { name: "New Task", exact: true }).tap();
await page.getByPlaceholder("Task title").fill("Keep this touch selection draft");
await page.getByRole("button", { name: "Assignee", exact: true }).tap();
const assignees = page.getByRole("dialog", { name: "Select assignee", exact: true });
const lastAssignee = await swipeToLastOption(page, assignees);
await expect(lastAssignee).toHaveText("Touch Agent 22");
await lastAssignee.tap();
// Assignee confirmation advances to the project picker.
await page.getByRole("dialog", { name: "Select project", exact: true }).getByRole("button", { name: "No project", exact: true }).tap();
await expect(page.getByRole("button", { name: "Touch Agent 22", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Codex options", exact: true }).tap();
await page.getByRole("radio", { name: "Custom", exact: true }).tap();
await page.getByRole("button", { name: "Default model", exact: true }).tap();
// A reduced viewport exercises the space available when a phone keyboard opens.
await page.setViewportSize({ width: 390, height: 430 });
const models = page.getByRole("dialog", { name: "Default model", exact: true });
const lastModel = await swipeToLastOption(page, models);
await expect(lastModel).toHaveText("Touch Model 24");
await lastModel.tap();
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "Touch Model 24", exact: true })).toBeVisible();
await expect(page.getByPlaceholder("Task title")).toHaveValue("Keep this touch selection draft");
// Reopening and closing the nested modal must preserve the outer task draft.
await page.getByRole("button", { name: "Touch Model 24", exact: true }).tap();
await page.getByRole("button", { name: "Close selector", exact: true }).tap();
await expect(models).toBeHidden();
await expect(page.getByRole("button", { name: "Touch Model 24", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Touch Agent 22", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Create Task", exact: true })).toBeEnabled();
await page.getByRole("button", { name: "Touch Model 24", exact: true }).tap();
await page.getByPlaceholder("Search models...").press("Escape");
await expect(models).toBeHidden();
await expect(page.getByRole("button", { name: "Touch Model 24", exact: true })).toBeFocused();
await page.getByRole("button", { name: "Touch Model 24", exact: true }).tap();
await page.touchscreen.tap(4, 4);
await expect(models).toBeHidden();
await expect(page.getByPlaceholder("Task title")).toHaveValue("Keep this touch selection draft");
await page.screenshot({ path: testInfo.outputPath("selected-mobile-assignee-and-model.png") });
// The desktop popover keeps mouse opening and keyboard selection.
await page.setViewportSize({ width: 1280, height: 900 });
await page.getByRole("button", { name: "Touch Model 24", exact: true }).click();
await page.getByPlaceholder("Search models...").fill("Touch Model 01");
await page.getByPlaceholder("Search models...").press("Enter");
await expect(page.getByRole("button", { name: "Touch Model 01", exact: true })).toBeVisible();
});