fix(ui): keep mobile task pickers in view (#14249)

## 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.
- Keep the mobile picker at the visible bottom edge.
- 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. Both picker boxes were
inside the 390 by 664 CSS viewport.
- The assignee picker box was `x=16, y=366, width=358, height=282`.
- The project picker box was `x=16, y=366, width=358, height=282`.

## 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:
DottaandPaperclip authored and GitHub committed 2026-09-27 11:02:15 +00:00
1 parent be43c23e2d
commit 74508357fa
4 files changed
+57 -13

No files matched your search

+1 -3
View File
@@ -1313,9 +1313,8 @@ describe("NewIssueDialog", () => {
});
expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-height")).toBe("420px");
expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-offset-top")).toBe("24px");
expect(dialogContent?.style.getPropertyValue("--new-issue-dialog-top")).toBe(
"calc(var(--new-issue-visual-viewport-offset-top) + var(--new-issue-dialog-top-gap))",
"var(--new-issue-dialog-top-gap)",
);
expect(dialogContent?.style.getPropertyValue("--new-issue-dialog-height")).toBe(
"calc(var(--new-issue-visual-viewport-height) - var(--new-issue-dialog-top-gap) - var(--new-issue-dialog-bottom-gap))",
@@ -1372,7 +1371,6 @@ describe("NewIssueDialog", () => {
});
expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-height")).toBe("420px");
expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-offset-top")).toBe("24px");
expect(dialogContent?.style.top).toBe("var(--new-issue-dialog-top)");
expect(dialogContent?.style.height).toBe("var(--new-issue-dialog-height)");
+4 -6
View File
@@ -96,14 +96,12 @@ type VisualViewportLayout = {
type NewIssueDialogViewportStyle = CSSProperties & {
"--new-issue-visual-viewport-height"?: string;
"--new-issue-visual-viewport-offset-top"?: string;
"--new-issue-dialog-top"?: string;
"--new-issue-dialog-height"?: string;
};
type MobileEntityPickerViewportStyle = CSSProperties & {
"--mobile-entity-picker-visual-viewport-height"?: string;
"--mobile-entity-picker-visual-viewport-bottom"?: string;
};
function readVisualViewportLayout(): VisualViewportLayout | null {
@@ -1327,8 +1325,10 @@ export function NewIssueDialog() {
const CurrentWorkModeIcon = currentWorkMode.icon;
const dialogViewportStyle = useMemo<NewIssueDialogViewportStyle>(() => {
const dialogGeometry = {
"--new-issue-dialog-top":
"calc(var(--new-issue-visual-viewport-offset-top) + var(--new-issue-dialog-top-gap))",
// Fixed-position coordinates are relative to Safari's visual viewport.
// Adding visualViewport.offsetTop here places the dialog below that
// viewport after the software keyboard pans the page.
"--new-issue-dialog-top": "var(--new-issue-dialog-top-gap)",
"--new-issue-dialog-height":
"calc(var(--new-issue-visual-viewport-height) - var(--new-issue-dialog-top-gap) - var(--new-issue-dialog-bottom-gap))",
};
@@ -1336,7 +1336,6 @@ export function NewIssueDialog() {
return {
...dialogGeometry,
"--new-issue-visual-viewport-height": `${visualViewportLayout.height}px`,
"--new-issue-visual-viewport-offset-top": `${visualViewportLayout.offsetTop}px`,
...(visualViewportLayout.constrained
? {
top: "var(--new-issue-dialog-top)",
@@ -1350,7 +1349,6 @@ export function NewIssueDialog() {
if (!visualViewportLayout) return {};
return {
"--mobile-entity-picker-visual-viewport-height": `${visualViewportLayout.height}px`,
"--mobile-entity-picker-visual-viewport-bottom": `${visualViewportLayout.offsetTop + visualViewportLayout.height}px`,
};
}, [visualViewportLayout]);
+9 -4
View File
@@ -231,7 +231,6 @@
--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-visual-viewport-bottom: 100dvh;
/* ────────────────────────────────────────────────────────────────────────
Motion tokens — chat-style task thread (default; classic legacy view sits
@@ -3008,20 +3007,26 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
viewport resizing. */
@media (max-width: 40rem) {
[data-radix-popper-content-wrapper]:has(> [data-mobile-entity-picker]) {
inset: 0 !important;
width: 100% !important;
height: 100% !important;
min-width: 0 !important;
transform: none !important;
will-change: auto !important;
pointer-events: none;
}
[data-mobile-entity-picker] {
position: fixed !important;
inset: calc(var(--mobile-entity-picker-visual-viewport-bottom) - var(--spacing) * 4)
calc(var(--spacing) * 4) auto !important;
inset: auto calc(var(--spacing) * 4) calc(var(--spacing) * 4) !important;
width: auto !important;
max-width: none !important;
max-height: calc(var(--mobile-entity-picker-visual-viewport-height) - var(--spacing) * 8) !important;
transform: none !important;
translate: 0 -100% !important;
translate: none !important;
transform-origin: bottom center !important;
animation: none !important;
pointer-events: auto;
}
[data-mobile-entity-picker] [data-slot="command-list"],
@@ -1,5 +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 type {
DocumentRevision,
ExecutionWorkspaceCloseReadiness,
@@ -716,6 +717,48 @@ export const NewIssuePrefilled: Story = {
),
};
export const NewIssueMobileAssigneePicker: Story = {
name: "New Issue - Mobile Assignee Picker",
parameters: {
viewport: { defaultViewport: "iphone14" },
},
render: () => (
<DialogStory
eyebrow="NewIssueDialog"
title="Mobile assignee picker"
description="The real new-task dialog with its assignee sheet open at an iOS viewport size."
badges={["iOS", "mobile", "assignee picker"]}
>
<IssueDialogOpener variant="empty" />
</DialogStory>
),
play: async () => {
const page = within(document.body);
await userEvent.click(await page.findByRole("button", { name: "Assignee" }));
},
};
export const NewIssueMobileProjectPicker: Story = {
name: "New Issue - Mobile Project Picker",
parameters: {
viewport: { defaultViewport: "iphone14" },
},
render: () => (
<DialogStory
eyebrow="NewIssueDialog"
title="Mobile project picker"
description="The real new-task dialog with its project sheet open at an iOS viewport size."
badges={["iOS", "mobile", "project picker"]}
>
<IssueDialogOpener variant="empty" />
</DialogStory>
),
play: async () => {
const page = within(document.body);
await userEvent.click(await page.findByRole("button", { name: "Project" }));
},
};
export const NewIssueValidationError: Story = {
name: "New Issue - Validation Error",
render: () => (