From d423fc4411145eb3745b1db652ae87ec0bea892e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:33:24 -0500 Subject: [PATCH] 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 --- .../components/InlineEntitySelector.test.tsx | 12 ++++- ui/src/components/InlineEntitySelector.tsx | 46 +++++++++++++++++-- ui/src/components/NewIssueDialog.tsx | 2 + ui/src/components/SearchableSelect.test.tsx | 2 + ui/src/components/SearchableSelect.tsx | 20 +++++++- .../components/task-chat/TaskChatComposer.tsx | 1 + .../useMobileEntityPickerViewportStyle.ts | 39 ++++++++++++++++ ui/src/index.css | 43 ++++++++++++++--- .../stories/dialogs-modals.stories.tsx | 35 ++++++++++++-- .../stories/mobile-entity-pickers.stories.tsx | 41 +++++++++++++++++ 10 files changed, 226 insertions(+), 15 deletions(-) create mode 100644 ui/src/hooks/useMobileEntityPickerViewportStyle.ts diff --git a/ui/src/components/InlineEntitySelector.test.tsx b/ui/src/components/InlineEntitySelector.test.tsx index 1ae6f681bd..a219e98728 100644 --- a/ui/src/components/InlineEntitySelector.test.tsx +++ b/ui/src/components/InlineEntitySelector.test.tsx @@ -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(); }); diff --git a/ui/src/components/InlineEntitySelector.tsx b/ui/src/components/InlineEntitySelector.tsx index 283a81bc5e..e7c62545a5 100644 --- a/ui/src/components/InlineEntitySelector.tsx +++ b/ui/src/components/InlineEntitySelector.tsx @@ -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( function InlineEntitySelector( { @@ -60,12 +82,15 @@ export const InlineEntitySelector = forwardRef(null); const shouldPreventCloseAutoFocusRef = useRef(false); @@ -146,12 +171,13 @@ export const InlineEntitySelector = forwardRef { event.preventDefault(); inputRef.current?.focus(); @@ -162,6 +188,20 @@ export const InlineEntitySelector = forwardRef +
+ {mobileTitle ?? placeholder} + +
{ 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 () => { diff --git a/ui/src/components/SearchableSelect.tsx b/ui/src/components/SearchableSelect.tsx index 8fdc8b89f8..bf69dfacb6 100644 --- a/ui/src/components/SearchableSelect.tsx +++ b/ui/src/components/SearchableSelect.tsx @@ -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 { 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) { 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< +
+ {mobileTitle ?? placeholder} + +
0 + ? height + : null; +} + +/** Keeps mobile picker sheets inside the live viewport when a keyboard opens. */ +export function useMobileEntityPickerViewportStyle(): MobileEntityPickerViewportStyle { + const [height, setHeight] = useState(() => + 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` }; +} diff --git a/ui/src/index.css b/ui/src/index.css index f1b89a779b..9923f3afb3 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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 { diff --git a/ui/storybook/stories/dialogs-modals.stories.tsx b/ui/storybook/stories/dialogs-modals.stories.tsx index 74e1221aa7..4e04fad60c 100644 --- a/ui/storybook/stories/dialogs-modals.stories.tsx +++ b/ui/storybook/stories/dialogs-modals.stories.tsx @@ -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; +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: () => ( { 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("[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: () => ( { 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("[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), + ); }, }; diff --git a/ui/storybook/stories/mobile-entity-pickers.stories.tsx b/ui/storybook/stories/mobile-entity-pickers.stories.tsx index 50d32adc97..b3972ef2d1 100644 --- a/ui/storybook/stories/mobile-entity-pickers.stories.tsx +++ b/ui/storybook/stories/mobile-entity-pickers.stories.tsx @@ -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: () => , }; + +export const ComposerAssigneePicker: Story = { + render: () => ( +
+ undefined} + workMode="standard" + mobile + enableReassign + reassignOptions={assignees.map((option) => ({ ...option, id: `agent:${option.id}` }))} + currentAssigneeValue="" + /> +
+ ), + play: async () => { + const page = within(document.body); + await userEvent.click(await page.findByTestId("task-chat-composer-assignee")); + }, +}; + +export const SearchableSelectModal: Story = { + render: () => ( +
+ + 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..." + /> +
+ ), + play: async () => { + const page = within(document.body); + await userEvent.click(await page.findByRole("combobox")); + }, +};