Files
PaperClipAI/tests/e2e/new-task-picker-touch.spec.ts
T
f2593b7905 feat: make Coordinator and Agent Chat standard and speed up navigation (#19360)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People enter that work through the workspace, agent conversations,
and tasks.
> - Coordinator and Agent Chat still require experimental enrollment.
> - Task link prefetches do not warm the compact queries used by the
default task view. Chat also waits for a request when its navigation
list already holds the conversation.
> - This pull request makes Coordinator and Agent Chat standard and
reuses the reads needed for navigation.
> - People can choose Classic workspace in Experimental settings. Cached
work can appear while fresh data loads.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Workspace selection, Agent Chat availability, and navigation between
Chat, Tasks, and task details.

**Subsystem affected**

Cross-cutting: UI, workspace preferences, instance settings, and the
database migration.

**Current behavior**

People must enable Coordinator and Agent Chat in Experimental settings.
Opening a task can repeat requests because prefetch warms the full
detail and comment queries, while the default view reads compact task
data and a recent comment window. Opening Chat waits for a detail
request even when the current person's conversation list contains it.

**Proposed behavior**

Coordinator is the standard workspace for everyone. Experimental
settings offer Classic workspace, off by default. Old Coordinator
experiment values do not select Classic. Agent Chat is available to
everyone, including instances with an old saved or managed off value.
Task link intent warms the queries used by the mounted view. Chat can
use the current person's company-scoped conversation list during
refresh.

**Reason and benefit**

People can use the standard workspace without setup. Repeat navigation
can show cached content while the server refreshes it.

**Breaking changes**

Existing Coordinator experiment and Agent Chat off values no longer
disable these surfaces. Classic requires an explicit new opt-in. The
workspace API keeps its existing response shape and audit checks. The
migration retains legacy values and adds a separate Classic preference.
Execution permissions, company access, and chat privacy checks still
apply.

**Additional context**

Roadmap check: this graduates existing surfaces and improves their
navigation. It does not add a new agent execution capability. Duplicate
searches found no open Coordinator workspace PR. Related server cache
work, Refs: #17828, addresses compact issue-list invalidation; this PR
addresses client navigation queries.

## What Changed

- Add a default-off Classic workspace preference. Derive the existing
workspace API response from that choice. Keep personal persistence,
account isolation, and audited writes in the workspace preference
service.
- Replace the Coordinator experiment control with Classic workspace. Use
Coordinator while preferences load. Update the existing settings
examples and navigation recipe.
- Retire the Agent Chat experiment control. Ignore old stored and
managed off values. Retain the compatibility key and the existing
execution and access checks.
- Reuse account- and company-scoped chat list data with its original
freshness timestamp.
- Share compact task and recent-comment query options between prefetch
and the task view. Keep full-history prefetch for Classic tasks and
expanded history.
- Add upgrade, replay, cache, account isolation, and browser switching
regressions. Update product, database, development, and roadmap
documentation.

## Verification

- Targeted preference, settings, migration, cache, and UI regression
tests passed during implementation. The cache tests hold refresh
requests pending and check that content still renders. They also check
account isolation and stale refresh behavior.
- A real browser test verified fresh Coordinator defaults, Classic
opt-in persistence, and return to Coordinator. It used an isolated
source checkout with fresh data on macOS.
- Token, migration numbering and safety, map, and Storybook catalog
checks pass after the rebase.
- Repository typecheck and build pass. Typecheck and token gates also
pass after review repairs. The broad local run passed all six chat
integration shards (1,931 tests). It then found the host-CLI-dependent
recovery fixture described below. Its remaining duplicate work was
stopped after the full current-head CI matrix passed; this is not a
claim that the broad local command completed green.
- Review repairs passed 419 UI/migration fixture tests, 19 Coordinator
navigation tests, and all 214 tests in the five affected server suites.
The 24 Chat/workspace browser tests pass, including standard Coordinator
switching and explicit Classic persistence.
- The broader browser repair run passed 85 tests. It retained two failed
assertions and skipped five later serial cases. The affected files then
passed all 11 tests after the fixes: reopen the desktop panel after
reload and open More for Audit and Connectors. This includes the five
serial cases that had not run.
- The broad local suite exposed a recovery fixture that executed the
host Codex CLI during a simulated remote probe. On a host with Codex
0.154.0, model fallback broke its fixed-model assertion. The fake
sandbox now reports Codex 0.160.0. All 20 selected recovery/provider
compatibility cases pass after the repair.
- A CLI company import/export suite timed out during server startup in
the second CI run. The port fallback is intentional in that fixture. No
root cause was established. That suite passes on the final commit in CI.
This records recovery, not a claimed product fix for the earlier
timeout.
- The first CI run exposed remaining assumptions about the retired flags
and Classic UI. The repair commit updates those fixtures and navigation
steps. It also restores the missing enabled Cases destination in
Coordinator. Earlier local recovery attempts used the wrong fixture
notification and advanced fake time during live execution; the corrected
tests use the subscribed recovery queue and wall time during execution.
- Greptile reviewed commit `98972b030d4505d838427a2fca6b196b8cad6d47` at
**5/5** with no new comments. The original finding is resolved.
- Current-head CI is green: **56 successful checks**, with three checks
skipped by workflow filters. This includes typecheck, build, the
complete Vitest matrix, all eight browser shards, Runner verification,
release checks, and security checks. [Final CI
run](https://github.com/paperclipai/paperclip/actions/runs/42132651414).
- Typecheck caught missing `pid` and `startedAt` fields in the simulated
command result. The fixture now returns the complete process contract,
and the server test-layer typecheck passes.
- Storybook catalog alignment was checked. Storybook play tests were not
run. Hosted latency and cold bundle loading were not measured.

## Risks

- The new default changes the workspace for people who previously left
the Coordinator experiment off. They can opt into Classic in
Experimental settings.
- The additive migration must run before the new server code. It is safe
to replay and preserves explicit Classic choices.
- Cached content can be stale during background refresh. Existing query
invalidation and source timestamps still control freshness.
- These changes reduce avoidable loading waits. They do not establish a
measured improvement in hosted request latency or initial bundle
download time.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools, code
execution, and browser automation. The exact serving revision 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: Scott Tong <scott@scottsmbpm5max.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-05 16:28:25 -08:00

254 lines
14 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { expect, test, type APIResponse, type Locator, type Page } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.route("**/api/workspace-preferences/me", route => route.fulfill({ json: {
userId: "local-board", coordinatorWorkspaceEnabled: false, updatedAt: null,
} }));
});
async function json(response: APIResponse) {
expect(response.ok(), await response.text()).toBe(true);
return response.json();
}
async function swipeToLastOption(page: Page, picker: Locator, list = picker.getByRole("listbox"), last = list.getByRole("option").last()) {
// The shared picker animates its height between views. Native touch
// coordinates must use the settled sheet rather than its clipped first frame.
await expect.poll(() => picker.evaluate((element) => {
const body = element.querySelector(".composer-run-settings-height");
return [...element.getAnimations(), ...(body?.getAnimations() ?? [])]
.every((animation) => animation.playState !== "running");
})).toBe(true);
// 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);
expect((await picker.boundingBox())!.y).toBeGreaterThanOrEqual(0);
const session = await page.context().newCDPSession(page);
const targetHandle = await last.elementHandle();
expect(targetHandle).not.toBeNull();
try {
expect(await list.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
for (let attempt = 0; attempt < 20; attempt += 1) {
// Momentum can finish between protocol round trips. Observe geometry and
// scrollTop together so a stale target cannot trigger a swipe at the end.
const { bounds, target, before } = await list.evaluate((element, option) => {
const container = element.getBoundingClientRect();
const item = option!.getBoundingClientRect();
return {
bounds: { x: container.x, y: container.y, width: container.width, height: container.height },
target: { y: item.y, height: item.height },
before: element.scrollTop,
};
}, targetHandle);
if (target.y >= bounds.y && target.y + target.height <= bounds.y + bounds.height) break;
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 targetHandle?.dispose();
await session.detach();
}
return last;
}
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
test("touch scroll observation stays valid when momentum finishes between browser reads", async ({ page, browserName }) => {
test.skip(browserName !== "chromium", "Native touch drags use Chromium's input protocol.");
await page.setContent(`<div role="dialog" style="position:absolute;top:20px;left:20px;width:300px">
<div role="listbox" style="height:150px;overflow-y:auto;touch-action:pan-y">
${Array.from({ length: 12 }, (_, index) => `<div role="option" style="height:40px">Option ${index}</div>`).join("")}
</div></div>`);
const picker = page.getByRole("dialog"), list = picker.getByRole("listbox"), last = list.getByRole("option").last();
const boundingBox = last.boundingBox.bind(last);
// Reproduce the observed compositor interleaving: a returned target rectangle
// is stale by the next scrollTop read because inertia reached the list end.
last.boundingBox = async (options) => {
const beforeMomentum = await boundingBox(options);
await list.evaluate((element) => { element.scrollTop = element.scrollHeight - element.clientHeight; });
return beforeMomentum;
};
await swipeToLastOption(page, picker, list, last);
await expect(picker).toBeVisible();
});
test("new-task assignee, model, and project 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", runner: "legacy", adapterConfig: { model: "gpt-6-sol" },
runtimeConfig: { heartbeat: { enabled: false } },
},
}));
await json(await request.post(`/api/companies/${company.id}/projects`, {
data: { name: `Touch Project ${String(index).padStart(2, "0")}` },
}));
}
// 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();
const draft = page.getByRole("textbox", { name: "editable markdown" });
await draft.fill("Keep this touch selection draft");
const assigneeTrigger = page.getByRole("button", { name: "Select assignee", exact: true });
const modelTrigger = page.getByRole("button", { name: "Select model and effort", exact: true });
await assigneeTrigger.tap();
const assigneePicker = page.getByRole("dialog", { name: "Select assignee", exact: true });
const lastAssignee = await swipeToLastOption(page, assigneePicker);
await expect(lastAssignee).toContainText("Touch Agent 22");
await lastAssignee.tap();
await expect(assigneePicker).toBeHidden();
await expect(assigneeTrigger).toBeFocused();
await modelTrigger.tap();
const picker = page.getByRole("dialog", { name: "Select model and effort", exact: true });
// The settings view must keep its controls stretched across the mobile sheet.
await expect.poll(async () => {
const sheet = (await picker.boundingBox())!;
const control = (await picker.getByRole("button", { name: "Choose exact model" }).boundingBox())!;
return control.width / sheet.width;
}).toBeGreaterThan(0.8);
// Short screens must also let a finger reach the settings below the fold.
const effort = picker.getByRole("slider", { name: "Effort" });
const effortBeforeScroll = await effort.inputValue();
await page.setViewportSize({ width: 390, height: 200 });
await swipeToLastOption(page, picker, picker.getByTestId("composer-run-settings-view"), effort);
await expect(effort).toHaveValue(effortBeforeScroll);
await page.screenshot({ path: testInfo.outputPath("settings-after-touch-scroll.png") });
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole("button", { name: "Choose exact model" }).tap();
await page.screenshot({ path: testInfo.outputPath("model-before-keyboard.png") });
// A reduced viewport exercises the space available when a phone keyboard opens.
await page.setViewportSize({ width: 390, height: 430 });
const lastModel = await swipeToLastOption(page, picker);
await expect(lastModel).toContainText("Touch Model 24");
await page.screenshot({ path: testInfo.outputPath("model-after-touch-scroll.png") });
await lastModel.tap();
await page.getByRole("button", { name: "Close picker" }).tap();
await page.setViewportSize({ width: 390, height: 844 });
await expect(assigneeTrigger).toContainText("Touch Agent 22");
await expect(modelTrigger).toContainText("Touch Model 24");
await expect(draft).toHaveText("Keep this touch selection draft");
const projectTrigger = page.getByRole("button", { name: "Project", exact: true });
await projectTrigger.tap();
const projectPicker = page.getByRole("dialog", { name: "Select project", exact: true });
await page.setViewportSize({ width: 390, height: 430 });
const projectList = projectPicker.locator("[data-mobile-entity-picker-list]");
const lastProject = await swipeToLastOption(page, projectPicker, projectList, projectList.getByRole("button").filter({ hasText: "Touch Project 22" }));
await page.screenshot({ path: testInfo.outputPath("project-after-touch-scroll.png") });
await lastProject.tap();
await expect(projectPicker).toBeHidden();
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('[data-slot="new-issue-compact-control"]').filter({ hasText: "Touch Project 22" })).toBeVisible();
await expect(draft).toHaveText("Keep this touch selection draft");
// Closing the nested picker preserves the outer composer draft and choices.
await modelTrigger.tap();
await page.getByRole("button", { name: "Choose exact model" }).tap();
await page.getByRole("searchbox", { name: "Search or paste a model ID" }).press("Escape");
await expect(picker).toBeHidden();
await expect(assigneeTrigger).toBeFocused();
await expect(page.getByRole("button", { name: "Create task", exact: true })).toBeEnabled();
await page.screenshot({ path: testInfo.outputPath("selected-mobile-picker-values.png") });
// The desktop picker uses the same selected values and supports model search.
await page.setViewportSize({ width: 1280, height: 900 });
await modelTrigger.click();
await page.getByRole("button", { name: "Choose exact model" }).click();
await page.getByRole("searchbox", { name: "Search or paste a model ID" }).fill("Touch Model 01");
await page.getByRole("option", { name: "Touch Model 01 touch-model-01", exact: true }).click();
await page.getByRole("button", { name: "Choose exact model" }).press("Escape");
await expect(modelTrigger).toContainText("Touch Model 01");
});
test.describe("desktop picker scrolling", () => {
test.use({ viewport: { width: 1280, height: 900 }, hasTouch: false, isMobile: false });
test("mouse wheels scroll nested model, assignee, and project lists without losing the draft", async ({ page, request }) => {
const company = await json(await request.post("/api/companies", {
data: { name: `Wheel pickers ${randomUUID()}` },
}));
for (let index = 1; index <= 22; index += 1) {
await json(await request.post(`/api/companies/${company.id}/agents`, {
data: {
name: `Wheel Agent ${String(index).padStart(2, "0")}`, role: "engineer",
adapterType: "codex_local", runner: "legacy", adapterConfig: { model: "gpt-6-sol" },
runtimeConfig: { heartbeat: { enabled: false } },
},
}));
await json(await request.post(`/api/companies/${company.id}/projects`, {
data: { name: `Wheel Project ${String(index).padStart(2, "0")}` },
}));
}
await page.route(`**/api/companies/${company.id}/adapters/codex_local/models*`, (route) => route.fulfill({
json: Array.from({ length: 24 }, (_, index) => ({
id: `wheel-model-${String(index + 1).padStart(2, "0")}`,
label: `Wheel Model ${String(index + 1).padStart(2, "0")}`,
})),
}));
await page.goto(`/${company.issuePrefix}/dashboard`);
await page.getByRole("button", { name: "New Task", exact: true }).click();
const draft = page.getByRole("textbox", { name: "editable markdown" });
await draft.fill("Keep this mouse wheel selection draft");
const wheelToBottom = async (list: Locator) => {
await expect.poll(() => list.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
const before = await list.evaluate((element) => element.scrollTop);
await list.hover();
await page.mouse.wheel(0, 2000);
await expect.poll(() => list.evaluate((element) => element.scrollTop)).toBeGreaterThan(before);
};
const assigneeTrigger = page.getByRole("button", { name: "Select assignee", exact: true });
const modelTrigger = page.getByRole("button", { name: "Select model and effort", exact: true });
await assigneeTrigger.click();
await wheelToBottom(page.getByRole("listbox", { name: "Assignees", exact: true }));
await page.getByRole("option").filter({ hasText: "Wheel Agent 22" }).click();
await expect(assigneeTrigger).toContainText("Wheel Agent 22");
await modelTrigger.click();
await page.getByRole("button", { name: "Choose exact model" }).click();
await wheelToBottom(page.getByRole("listbox", { name: "Models", exact: true }));
await page.getByRole("option").filter({ hasText: "Wheel Model 24" }).click();
await page.getByRole("button", { name: "Choose exact model" }).press("Escape");
await expect(modelTrigger).toContainText("Wheel Model 24");
await page.getByRole("button", { name: "Project", exact: true }).click();
const projectPicker = page.getByRole("dialog", { name: "Select project", exact: true });
await wheelToBottom(projectPicker.locator("[data-mobile-entity-picker-list]"));
await projectPicker.getByRole("button").filter({ hasText: "Wheel Project 22" }).click();
await expect(projectPicker).toBeHidden();
await expect(page.locator('[data-slot="new-issue-compact-control"]').filter({ hasText: "Wheel Project 22" })).toBeVisible();
await expect(draft).toHaveText("Keep this mouse wheel selection draft");
});
});