mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 00:54:38 +02:00
## 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>
123 lines
6.8 KiB
TypeScript
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();
|
|
});
|