fix(ui): use large mobile selector modals (#14250)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The new task dialog lets an operator assign work on a phone.
> - The mobile picker used fixed coordinates inside the Radix wrapper.
> - iOS Safari already makes fixed coordinates relative to the visual
viewport.
> - The old code added the visual viewport offset a second time.
> - This pull request removes the second offset and gives the mobile
wrapper full viewport geometry.
> - The benefit is a visible picker and search field when the iOS
keyboard opens.

## Linked Issues or Issue Description

**What happened?**

The assignee and project pickers could move outside the visible screen
in the new task dialog on iOS.

**What did you expect to happen?**

The picker and its search field must stay visible above the bottom edge
and the software keyboard.

**Steps to reproduce**

Open the new task dialog on an iPhone. Select Assignee or Project. The
picker can move outside the visual viewport when Safari pans the page.

**Paperclip version or commit**

The problem exists after the change in PR #13343.

**Deployment mode**

The problem affects the board UI in local and authenticated modes.

Refs #13343

## What Changed

- Use visual viewport local coordinates for the new task dialog.
- Give the mobile Radix popper wrapper full viewport geometry.
- Render both shared selector components as large, titled mobile modals.
- Keep desktop selectors as anchored popovers.
- Cover the new-task assignee, new-task project, composer assignee, and
generic searchable selector in Storybook.
- Add iPhone-sized Storybook states for the assignee and project
pickers.
- Update viewport tests for the corrected coordinate model.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/components/NewIssueDialog.test.tsx
src/components/InlineEntitySelector.test.tsx`
- `pnpm check:token-gates`
- Playwright used the iPhone 14 device profile. All mobile modals were
inside the 390 by 664 CSS viewport.
- Each mobile modal was `x=16, y=16, width=358, height=632`.
- A desktop check kept the anchored popover at `width=320, height=211`.

## Risks

- Low risk. The CSS only changes narrow mobile viewports.
- The full-screen popper wrapper does not receive pointer events. The
picker still receives pointer events.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5.6, Codex agent with tool use and code execution.

## 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 and contains no internal task
ID
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant Storybook documentation to reflect my
changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] 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>
This commit is contained in:
Dotta
2026-09-27 11:33:24 +00:00
committed by GitHub
co-authored by Paperclip
parent 74508357fa
commit d423fc4411
10 changed files with 226 additions and 15 deletions
@@ -91,7 +91,7 @@ describe("InlineEntitySelector", () => {
it("focuses the search input when opened on coarse pointers", async () => {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query === "(pointer: coarse)",
matches: query === "(pointer: coarse)" || query === "(max-width: 40rem)",
media: query,
onchange: null,
addEventListener: vi.fn(),
@@ -116,6 +116,7 @@ describe("InlineEntitySelector", () => {
searchPlaceholder="Search responsible..."
emptyMessage="No responsible found."
onChange={vi.fn()}
disablePortal
/>,
);
});
@@ -131,8 +132,17 @@ describe("InlineEntitySelector", () => {
expect(searchInput).not.toBeNull();
expect(searchInput?.className).toContain("text-base");
expect(document.querySelector("[data-mobile-entity-picker]")).not.toBeNull();
expect(container.contains(document.querySelector("[data-mobile-entity-picker]"))).toBe(false);
expect(document.querySelector("[data-mobile-entity-picker-header]")?.textContent).toContain("Responsible");
expect(document.querySelector('button[aria-label="Close selector"]')).not.toBeNull();
expect(document.activeElement).toBe(searchInput);
await act(async () => {
(document.querySelector('button[aria-label="Close selector"]') as HTMLButtonElement | null)?.click();
await Promise.resolve();
});
expect(document.querySelector("[data-mobile-entity-picker]")).toBeNull();
act(() => {
root.unmount();
});
+43 -3
View File
@@ -1,8 +1,9 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Check } from "lucide-react";
import { Check, X } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { orderItemsBySelectedAndRecent } from "../lib/recent-selections";
import { cn } from "../lib/utils";
import { useMobileEntityPickerViewportStyle } from "../hooks/useMobileEntityPickerViewportStyle";
export interface InlineEntityOption {
id: string;
@@ -35,10 +36,31 @@ interface InlineEntitySelectorProps {
triggerDataSlot?: string;
/** Runtime geometry variables for the portalled mobile picker sheet. */
contentStyle?: CSSProperties;
/** Heading for the large mobile selector modal. Defaults to the placeholder. */
mobileTitle?: string;
}
const EMPTY_RECENT_OPTION_IDS: string[] = [];
function useMobileSelectorModal() {
const [mobile, setMobile] = useState(() =>
typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& window.matchMedia("(max-width: 40rem)").matches,
);
useEffect(() => {
if (typeof window.matchMedia !== "function") return;
const media = window.matchMedia("(max-width: 40rem)");
const update = () => setMobile(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
return mobile;
}
export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySelectorProps>(
function InlineEntitySelector(
{
@@ -60,12 +82,15 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
triggerTestId,
triggerDataSlot,
contentStyle,
mobileTitle,
},
ref,
) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
const mobileSelectorModal = useMobileSelectorModal();
const mobileViewportStyle = useMobileEntityPickerViewportStyle();
const highlightedIndexRef = useRef(0);
const inputRef = useRef<HTMLInputElement>(null);
const shouldPreventCloseAutoFocusRef = useRef(false);
@@ -146,12 +171,13 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
</PopoverTrigger>
<PopoverContent
data-mobile-entity-picker=""
aria-label={mobileTitle ?? placeholder}
align="start"
side="bottom"
collisionPadding={16}
className="w-(--sz-calc-6) p-1"
disablePortal={disablePortal}
style={contentStyle}
disablePortal={disablePortal && !mobileSelectorModal}
style={{ ...mobileViewportStyle, ...contentStyle }}
onOpenAutoFocus={(event) => {
event.preventDefault();
inputRef.current?.focus();
@@ -162,6 +188,20 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
shouldPreventCloseAutoFocusRef.current = false;
}}
>
<div data-mobile-entity-picker-header="" className="hidden items-center justify-between border-b border-border px-4 py-3">
<span className="text-base font-semibold text-foreground">{mobileTitle ?? placeholder}</span>
<button
type="button"
className="inline-flex size-9 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Close selector"
onClick={() => {
shouldPreventCloseAutoFocusRef.current = true;
setOpen(false);
}}
>
<X className="size-5" />
</button>
</div>
<input
ref={inputRef}
className="w-full border-b border-border bg-transparent px-2 py-1.5 text-base outline-none placeholder:text-muted-foreground/60 md:text-sm"
+2
View File
@@ -1513,6 +1513,7 @@ export function NewIssueDialog() {
options={assigneeOptions}
recentOptionIds={recentAssigneeOptionIds}
placeholder="Assignee"
mobileTitle="Select assignee"
className="h-8 px-2.5 py-0 sm:h-auto sm:px-2 sm:py-1"
triggerDataSlot="new-issue-compact-control"
contentStyle={entityPickerViewportStyle}
@@ -1574,6 +1575,7 @@ export function NewIssueDialog() {
options={projectOptions}
recentOptionIds={recentProjectIds}
placeholder="Project"
mobileTitle="Select project"
className="h-8 px-2.5 py-0 sm:h-auto sm:px-2 sm:py-1"
triggerDataSlot="new-issue-compact-control"
contentStyle={entityPickerViewportStyle}
@@ -131,6 +131,8 @@ describe("SearchableSelect", () => {
expect(container.querySelector("[data-option-key='recent:alpha']")).not.toBeNull();
expect(container.querySelector("[data-option-key='all:alpha']")).not.toBeNull();
expect(container.querySelector("[data-mobile-entity-picker]")).not.toBeNull();
expect(container.querySelector("[data-mobile-entity-picker-header]")?.textContent).toContain("Pick one");
expect(container.querySelector('button[aria-label="Close selector"]')).not.toBeNull();
});
it("filters options and returns the selected option object", async () => {
+19 -1
View File
@@ -1,4 +1,4 @@
import { Check, ChevronsUpDown } from "lucide-react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { useMemo, useRef, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -12,6 +12,7 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { fuzzyTextMatchesQuery, normalizeSearchText, scoreFuzzyTextFields } from "@/lib/searchable-select";
import { cn } from "@/lib/utils";
import { useMobileEntityPickerViewportStyle } from "@/hooks/useMobileEntityPickerViewportStyle";
export interface SearchableSelectOption<TValue extends string = string> {
key: string;
@@ -59,6 +60,8 @@ export interface SearchableSelectProps<
filterOption?: (option: TOption, query: string) => boolean;
scoreOption?: (option: TOption, query: string) => number | null;
disablePortal?: boolean;
/** Heading for the large mobile selector modal. Defaults to the placeholder. */
mobileTitle?: string;
/**
* Optional pinned "creatable" item rendered at the bottom of the list,
* regardless of the query (used e.g. by the secret picker's
@@ -107,10 +110,12 @@ export function SearchableSelect<
filterOption = defaultFilterOption,
scoreOption,
disablePortal,
mobileTitle,
createItem,
}: SearchableSelectProps<TValue, TOption>) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const mobileViewportStyle = useMobileEntityPickerViewportStyle();
const pointerFocusRef = useRef(false);
const suppressNextTriggerFocusRef = useRef(false);
@@ -226,6 +231,8 @@ export function SearchableSelect<
</PopoverTrigger>
<PopoverContent
data-mobile-entity-picker=""
aria-label={mobileTitle ?? placeholder}
style={mobileViewportStyle}
align={align}
collisionPadding={16}
disablePortal={disablePortal}
@@ -244,6 +251,17 @@ export function SearchableSelect<
}
}}
>
<div data-mobile-entity-picker-header="" className="hidden items-center justify-between border-b border-border px-4 py-3">
<span className="text-base font-semibold text-foreground">{mobileTitle ?? placeholder}</span>
<button
type="button"
className="inline-flex size-9 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Close selector"
onClick={() => closePopover({ suppressTriggerFocus: true })}
>
<X className="size-5" />
</button>
</div>
<Command shouldFilter={false}>
<CommandInput
value={query}
@@ -1509,6 +1509,7 @@ export function TaskChatComposer({
value={assigneeValue}
options={reassignOptions ?? []}
placeholder="Assignee"
mobileTitle="Select assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees…"
emptyMessage="No matches."
@@ -0,0 +1,39 @@
import { useEffect, useState, type CSSProperties } from "react";
export type MobileEntityPickerViewportStyle = CSSProperties & {
"--mobile-entity-picker-visual-viewport-height"?: string;
};
function readVisualViewportHeight() {
const height = window.visualViewport?.height;
return typeof height === "number" && Number.isFinite(height) && height > 0
? height
: null;
}
/** Keeps mobile picker sheets inside the live viewport when a keyboard opens. */
export function useMobileEntityPickerViewportStyle(): MobileEntityPickerViewportStyle {
const [height, setHeight] = useState<number | null>(() =>
typeof window === "undefined" ? null : readVisualViewportHeight(),
);
useEffect(() => {
const viewport = window.visualViewport;
if (!viewport) return;
const update = () => {
const next = readVisualViewportHeight();
if (next !== null) setHeight(next);
};
update();
viewport.addEventListener("resize", update);
viewport.addEventListener("scroll", update);
return () => {
viewport.removeEventListener("resize", update);
viewport.removeEventListener("scroll", update);
};
}, []);
return height === null
? {}
: { "--mobile-entity-picker-visual-viewport-height": `${height}px` };
}
+36 -7
View File
@@ -231,6 +231,7 @@
--new-issue-dialog-top-gap: max(1rem, env(safe-area-inset-top));
--new-issue-dialog-bottom-gap: max(1rem, env(safe-area-inset-bottom));
--mobile-entity-picker-visual-viewport-height: 100dvh;
--mobile-entity-picker-backdrop: color-mix(in oklab, var(--foreground) 50%, transparent);
/* ────────────────────────────────────────────────────────────────────────
Motion tokens — chat-style task thread (default; classic legacy view sits
@@ -3009,32 +3010,60 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
[data-radix-popper-content-wrapper]:has(> [data-mobile-entity-picker]) {
inset: 0 !important;
width: 100% !important;
height: 100% !important;
height: var(--mobile-entity-picker-visual-viewport-height) !important;
min-width: 0 !important;
transform: none !important;
will-change: auto !important;
pointer-events: none;
}
[data-radix-popper-content-wrapper]:has(> [data-mobile-entity-picker])::before {
content: "";
position: absolute;
inset: 0;
background: var(--mobile-entity-picker-backdrop);
pointer-events: none;
}
[data-mobile-entity-picker] {
position: fixed !important;
inset: auto calc(var(--spacing) * 4) calc(var(--spacing) * 4) !important;
inset:
max(calc(var(--spacing) * 4), env(safe-area-inset-top))
calc(var(--spacing) * 4)
auto !important;
width: auto !important;
max-width: none !important;
max-height: calc(var(--mobile-entity-picker-visual-viewport-height) - var(--spacing) * 8) !important;
height: calc(
var(--mobile-entity-picker-visual-viewport-height)
- max(calc(var(--spacing) * 4), env(safe-area-inset-top))
- max(calc(var(--spacing) * 4), env(safe-area-inset-bottom))
) !important;
max-height: none !important;
transform: none !important;
translate: none !important;
transform-origin: bottom center !important;
animation: none !important;
pointer-events: auto;
display: flex;
flex-direction: column;
padding: 0 !important;
overflow: hidden;
}
[data-mobile-entity-picker-header] {
display: flex;
}
[data-mobile-entity-picker] > [data-slot="command"] {
min-height: 0;
flex: 1;
}
[data-mobile-entity-picker] [data-slot="command-list"],
[data-mobile-entity-picker-list] {
max-height: min(
var(--sz-300px),
calc(var(--mobile-entity-picker-visual-viewport-height) - var(--spacing) * 24)
) !important;
max-height: none !important;
min-height: 0;
flex: 1;
}
}
:root {
@@ -1,6 +1,6 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { userEvent, within } from "storybook/test";
import { expect, userEvent, within } from "storybook/test";
import type {
DocumentRevision,
ExecutionWorkspaceCloseReadiness,
@@ -689,6 +689,15 @@ export default meta;
type Story = StoryObj<typeof meta>;
function constrainVisualViewportForPickerStory() {
const viewport = window.visualViewport;
if (!viewport) return null;
Object.defineProperty(viewport, "height", { configurable: true, value: 420 });
Object.defineProperty(viewport, "offsetTop", { configurable: true, value: 24 });
viewport.dispatchEvent(new Event("resize"));
return viewport;
}
export const NewIssueEmpty: Story = {
name: "New Issue - Empty",
render: () => (
@@ -720,7 +729,7 @@ export const NewIssuePrefilled: Story = {
export const NewIssueMobileAssigneePicker: Story = {
name: "New Issue - Mobile Assignee Picker",
parameters: {
viewport: { defaultViewport: "iphone14" },
viewport: { defaultViewport: "mobile" },
},
render: () => (
<DialogStory
@@ -735,13 +744,23 @@ export const NewIssueMobileAssigneePicker: Story = {
play: async () => {
const page = within(document.body);
await userEvent.click(await page.findByRole("button", { name: "Assignee" }));
const search = await page.findByPlaceholderText("Search assignees...");
await userEvent.click(search);
const viewport = constrainVisualViewportForPickerStory();
await new Promise((resolve) => requestAnimationFrame(resolve));
const bounds = search.closest<HTMLElement>("[data-mobile-entity-picker]")?.getBoundingClientRect();
const offsetTop = viewport?.offsetTop ?? 0;
await expect((bounds?.top ?? -1) + offsetTop).toBeGreaterThanOrEqual(offsetTop);
await expect((bounds?.bottom ?? Number.POSITIVE_INFINITY) + offsetTop).toBeLessThanOrEqual(
offsetTop + (viewport?.height ?? window.innerHeight),
);
},
};
export const NewIssueMobileProjectPicker: Story = {
name: "New Issue - Mobile Project Picker",
parameters: {
viewport: { defaultViewport: "iphone14" },
viewport: { defaultViewport: "mobile" },
},
render: () => (
<DialogStory
@@ -756,6 +775,16 @@ export const NewIssueMobileProjectPicker: Story = {
play: async () => {
const page = within(document.body);
await userEvent.click(await page.findByRole("button", { name: "Project" }));
const search = await page.findByPlaceholderText("Search projects...");
await userEvent.click(search);
const viewport = constrainVisualViewportForPickerStory();
await new Promise((resolve) => requestAnimationFrame(resolve));
const bounds = search.closest<HTMLElement>("[data-mobile-entity-picker]")?.getBoundingClientRect();
const offsetTop = viewport?.offsetTop ?? 0;
await expect((bounds?.top ?? -1) + offsetTop).toBeGreaterThanOrEqual(offsetTop);
await expect((bounds?.bottom ?? Number.POSITIVE_INFINITY) + offsetTop).toBeLessThanOrEqual(
offsetTop + (viewport?.height ?? window.innerHeight),
);
},
};
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { userEvent, within } from "storybook/test";
import { InlineEntitySelector, type InlineEntityOption } from "@/components/InlineEntitySelector";
import { SearchableSelect } from "@/components/SearchableSelect";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
const assignees: InlineEntityOption[] = [
{ id: "agent-product", label: "Product Lead", searchText: "planning product" },
@@ -56,3 +59,41 @@ export const AssigneePicker: Story = {
export const ProjectPicker: Story = {
render: () => <OpenPicker kind="Project" options={projects} />,
};
export const ComposerAssigneePicker: Story = {
render: () => (
<div className="flex min-h-screen items-end p-4">
<TaskChatComposer
onAdd={() => undefined}
workMode="standard"
mobile
enableReassign
reassignOptions={assignees.map((option) => ({ ...option, id: `agent:${option.id}` }))}
currentAssigneeValue=""
/>
</div>
),
play: async () => {
const page = within(document.body);
await userEvent.click(await page.findByTestId("task-chat-composer-assignee"));
},
};
export const SearchableSelectModal: Story = {
render: () => (
<div className="flex min-h-screen items-end p-4">
<SearchableSelect<string>
value=""
groups={[{ id: "projects", label: "Projects", options: projects.map((project) => ({ key: project.id, value: project.id, label: project.label })) }]}
onValueChange={() => undefined}
placeholder="Choose project"
mobileTitle="Select project"
searchPlaceholder="Search projects..."
/>
</div>
),
play: async () => {
const page = within(document.body);
await userEvent.click(await page.findByRole("combobox"));
},
};