mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
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:
1 parent
be43c23e2d
commit
74508357fa
4 files changed
+57
-13
No files matched your search
@@ -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)");
|
||||
|
||||
|
||||
@@ -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
@@ -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: () => (
|
||||
|
||||
Reference in new issue
Block a user