From 9c941169a6d8d15114cbe78791a2b7d979e573c3 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:49:05 -0400 Subject: [PATCH] fix(ui): keep new task dialog visible above mobile keyboard (#11281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators create tasks in a dialog that includes the assignee and project fields. > - Mobile browsers reduce and offset the visual viewport when the on-screen keyboard opens. > - The dialog used layout viewport units, so its upper fields could move off-screen while the user typed. > - This pull request makes the dialog follow the live visual viewport and keeps the focused editor visible. > - The benefit is that operators can see the task context and the field they edit on mobile devices. ## Linked Issues or Issue Description **What happened?** On mobile browsers, opening the keyboard in the new-task dialog could move the assignee and project fields above the visible screen. The active editor could also become difficult to see. **Expected behavior** The full dialog must stay inside the visible browser area. The active editor and task controls must remain reachable while the on-screen keyboard is open. **Steps to reproduce** 1. Open Paperclip on a mobile browser. 2. Open the new-task dialog. 3. Focus the title or description editor to open the on-screen keyboard. 4. Observe that the upper fields can move outside the visible viewport. **Paperclip version or commit** Reproduced before commit `838cdbb325` on `master`. **Deployment mode** Local dev (`pnpm dev`) in a mobile browser viewport. ## What Changed - Read `window.visualViewport` while the dialog is open. - Apply token-based dialog geometry when the visual viewport is constrained. - Keep the focused editor visible after viewport resize and scroll events. - Add unit coverage for visual viewport updates and focus scrolling. - Add Playwright coverage for mobile, tablet, desktop keyboard, and unconstrained desktop layouts. ## Verification - `pnpm exec vitest run ui/src/components/NewIssueDialog.test.tsx` — 27 tests passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — passed with all gates clean. - `pnpm --filter @paperclipai/ui build-storybook` — passed. - `pnpm exec playwright test tests/storybook-visual/new-issue-dialog-viewport.spec.ts --config tests/storybook-visual/playwright.config.ts` — 4 tests passed. ## Risks - Low risk. The custom geometry only activates when `visualViewport.height` is less than `window.innerHeight`. - Browsers without the Visual Viewport API keep the existing dialog primitive behavior. - The browser test checks hit targets and visible bounds at mobile, tablet, and desktop widths. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5. The session used reasoning, repository tools, shell execution, and browser automation. The service did not expose the context-window size. ## 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: Paperclip --- .../new-issue-dialog-viewport.spec.ts | 177 ++++++++++++++++++ ui/src/components/NewIssueDialog.test.tsx | 75 +++++++- ui/src/components/NewIssueDialog.tsx | 96 +++++++++- ui/src/index.css | 8 + 4 files changed, 351 insertions(+), 5 deletions(-) create mode 100644 tests/storybook-visual/new-issue-dialog-viewport.spec.ts diff --git a/tests/storybook-visual/new-issue-dialog-viewport.spec.ts b/tests/storybook-visual/new-issue-dialog-viewport.spec.ts new file mode 100644 index 0000000000..b5d154f0b6 --- /dev/null +++ b/tests/storybook-visual/new-issue-dialog-viewport.spec.ts @@ -0,0 +1,177 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +const STORY_ID = "product-dialogs-modals--new-issue-prefilled"; + +type ViewportCase = { + name: string; + width: number; + layoutHeight: number; + visualHeight: number; + offsetTop: number; +}; + +const VIEWPORT_CASES: ViewportCase[] = [ + { name: "mobile", width: 390, layoutHeight: 844, visualHeight: 408, offsetTop: 120 }, + { name: "tablet", width: 820, layoutHeight: 1180, visualHeight: 780, offsetTop: 120 }, + { name: "desktop with an on-screen keyboard", width: 1440, layoutHeight: 900, visualHeight: 600, offsetTop: 80 }, +]; + +async function installVisualViewport(page: Page, layoutHeight: number) { + await page.addInitScript(({ initialHeight }) => { + class TestVisualViewport extends EventTarget { + width = window.innerWidth; + height = initialHeight; + offsetLeft = 0; + offsetTop = 0; + pageLeft = 0; + pageTop = 0; + scale = 1; + } + + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: new TestVisualViewport(), + }); + }, { initialHeight: layoutHeight }); +} + +async function renderDialog(page: Page, viewport: ViewportCase) { + await page.setViewportSize({ width: viewport.width, height: viewport.layoutHeight }); + await installVisualViewport(page, viewport.layoutHeight); + await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story`, { waitUntil: "load" }); + await page.waitForFunction(() => { + const body = document.body; + return body.classList.contains("sb-show-main") || body.classList.contains("sb-show-errordisplay"); + }); + expect( + await page.locator(".sb-show-errordisplay").count(), + `story ${STORY_ID} threw during render`, + ).toBe(0); + + const dialog = page.locator('[data-slot="dialog-content"]'); + await expect(dialog).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("button", { name: "Create Task" })).toBeEnabled(); + return dialog; +} + +async function constrainVisualViewport(page: Page, viewport: ViewportCase) { + await page.evaluate(({ height, offsetTop }) => { + const visualViewport = window.visualViewport as VisualViewport & { + height: number; + offsetTop: number; + }; + visualViewport.height = height; + visualViewport.offsetTop = offsetTop; + visualViewport.dispatchEvent(new Event("resize")); + }, { height: viewport.visualHeight, offsetTop: viewport.offsetTop }); +} + +async function expectHitTarget(locator: Locator, visibleTop: number, visibleBottom: number) { + const result = await locator.evaluate((element, band) => { + const rect = element.getBoundingClientRect(); + const pointTarget = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); + return { + top: rect.top, + bottom: rect.bottom, + hit: pointTarget === element || element.contains(pointTarget), + visibleTop: band.visibleTop, + visibleBottom: band.visibleBottom, + }; + }, { visibleTop, visibleBottom }); + + expect(result.top).toBeGreaterThanOrEqual(result.visibleTop); + expect(result.bottom).toBeLessThanOrEqual(result.visibleBottom); + expect(result.hit).toBe(true); +} + +for (const viewport of VIEWPORT_CASES) { + test(`keeps the new-task dialog inside the constrained visual viewport at ${viewport.name} width`, async ({ page }) => { + const dialog = await renderDialog(page, viewport); + const descriptionEditor = dialog.locator('.paperclip-mdxeditor-content[contenteditable="true"]'); + await expect(descriptionEditor).toBeVisible(); + await descriptionEditor.focus(); + + await constrainVisualViewport(page, viewport); + + const visibleTop = viewport.offsetTop; + const visibleBottom = viewport.offsetTop + viewport.visualHeight; + await expect.poll(async () => dialog.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom }; + })).toEqual({ + top: viewport.offsetTop + 16, + bottom: viewport.offsetTop + viewport.visualHeight - 16, + }); + + const dialogGeometry = await dialog.evaluate((element) => { + const style = getComputedStyle(element); + return { + heightVariable: style.getPropertyValue("--new-issue-dialog-height").trim(), + rootHeightVariable: getComputedStyle(document.documentElement) + .getPropertyValue("--new-issue-dialog-height") + .trim(), + translate: style.translate, + }; + }); + expect(dialogGeometry.heightVariable).toContain(`${viewport.visualHeight}px`); + expect(dialogGeometry.heightVariable).not.toContain("100dvh"); + expect(dialogGeometry.heightVariable).not.toBe(dialogGeometry.rootHeightVariable); + expect(dialogGeometry.rootHeightVariable).toBe(""); + expect(dialogGeometry.translate).toBe("-50%"); + + const closeButton = dialog.locator("button").filter({ hasText: "×" }); + const createButton = page.getByRole("button", { name: "Create Task" }); + await expectHitTarget(closeButton, visibleTop, visibleBottom); + await expectHitTarget(createButton, visibleTop, visibleBottom); + + const scrollRegion = dialog.locator(".overflow-y-auto.overscroll-contain"); + const scrollMetrics = await scrollRegion.evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + overflowY: getComputedStyle(element).overflowY, + })); + expect(scrollMetrics.overflowY).toBe("auto"); + expect(scrollMetrics.scrollHeight).toBeGreaterThanOrEqual(scrollMetrics.clientHeight); + if (viewport.width === 390) { + expect(scrollMetrics.scrollHeight).toBeGreaterThan(scrollMetrics.clientHeight); + } + + const editorGeometry = await descriptionEditor.evaluate((element, scrollSelector) => { + const editorRect = element.getBoundingClientRect(); + const scrollRect = element.closest(scrollSelector)!.getBoundingClientRect(); + return { + editorTop: editorRect.top, + editorBottom: editorRect.bottom, + scrollTop: scrollRect.top, + scrollBottom: scrollRect.bottom, + }; + }, ".overflow-y-auto.overscroll-contain"); + expect(editorGeometry.editorTop).toBeGreaterThanOrEqual(editorGeometry.scrollTop); + expect(editorGeometry.editorBottom).toBeLessThanOrEqual(editorGeometry.scrollBottom); + + for (const name of ["CodexCoder", "Board UI"]) { + const selector = scrollRegion.getByRole("button", { name }); + await selector.scrollIntoViewIfNeeded(); + await expectHitTarget(selector, visibleTop, visibleBottom); + } + }); +} + +test("leaves unconstrained desktop positioning to the dialog primitive", async ({ page }) => { + const viewport: ViewportCase = { + name: "unconstrained desktop", + width: 1440, + layoutHeight: 900, + visualHeight: 900, + offsetTop: 0, + }; + const dialog = await renderDialog(page, viewport); + + const inlineStyle = await dialog.evaluate((element) => ({ + top: element.style.top, + height: element.style.height, + translate: element.style.translate, + })); + expect(inlineStyle).toEqual({ top: "", height: "", translate: "" }); + expect(await dialog.evaluate((element) => getComputedStyle(element).translate)).toBe("-50% -50%"); +}); diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index a41c886b2a..11c4b6ba69 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -318,10 +318,14 @@ function renderDialog(container: HTMLDivElement) { describe("NewIssueDialog", () => { let container: HTMLDivElement; let originalResizeObserver: typeof ResizeObserver | undefined; + let originalVisualViewportDescriptor: PropertyDescriptor | undefined; + let originalInnerHeightDescriptor: PropertyDescriptor | undefined; beforeEach(() => { vi.useRealTimers(); originalResizeObserver = globalThis.ResizeObserver; + originalVisualViewportDescriptor = Object.getOwnPropertyDescriptor(window, "visualViewport"); + originalInnerHeightDescriptor = Object.getOwnPropertyDescriptor(window, "innerHeight"); globalThis.ResizeObserver = class ResizeObserver { observe() {} unobserve() {} @@ -366,6 +370,16 @@ describe("NewIssueDialog", () => { afterEach(() => { globalThis.ResizeObserver = originalResizeObserver!; + if (originalVisualViewportDescriptor) { + Object.defineProperty(window, "visualViewport", originalVisualViewportDescriptor); + } else { + Reflect.deleteProperty(window, "visualViewport"); + } + if (originalInnerHeightDescriptor) { + Object.defineProperty(window, "innerHeight", originalInnerHeightDescriptor); + } else { + Reflect.deleteProperty(window, "innerHeight"); + } document.body.innerHTML = ""; }); @@ -1095,8 +1109,6 @@ describe("NewIssueDialog", () => { ); expect(dialogContent?.className).toContain("h-(--new-issue-dialog-height)"); expect(dialogContent?.className).toContain("overflow-hidden"); - expect(dialogContent?.getAttribute("style")).toContain("env(safe-area-inset-top)"); - expect(dialogContent?.getAttribute("style")).toContain("env(safe-area-inset-bottom)"); const titleInput = container.querySelector('textarea[placeholder="Task title"]'); const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]'); @@ -1111,6 +1123,65 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it("tracks the mobile visual viewport and keeps the focused editor visible above the keyboard", async () => { + const visualViewport = new EventTarget() as EventTarget & { + height: number; + offsetTop: number; + }; + visualViewport.height = 844; + visualViewport.offsetTop = 0; + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: visualViewport, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 844, + }); + + const { root } = renderDialog(container); + await flush(); + + const dialogContent = Array.from(container.querySelectorAll("div")).find((element) => + element.className.includes("max-h-(--new-issue-dialog-height)"), + ); + const descriptionInput = container.querySelector( + 'textarea[aria-label="Add description..."]', + ); + const scrollIntoView = vi.fn(); + Object.defineProperty(descriptionInput!, "scrollIntoView", { + configurable: true, + value: scrollIntoView, + }); + descriptionInput?.focus(); + + expect(dialogContent?.style.top).toBe(""); + expect(dialogContent?.style.height).toBe(""); + expect(dialogContent?.style.translate).toBe(""); + + visualViewport.height = 420; + visualViewport.offsetTop = 24; + await act(async () => { + visualViewport.dispatchEvent(new Event("resize")); + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + + 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))", + ); + 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))", + ); + expect(dialogContent?.style.top).toBe("var(--new-issue-dialog-top)"); + expect(dialogContent?.style.height).toBe("var(--new-issue-dialog-height)"); + expect(dialogContent?.style.translate).toBe("var(--pct-neg-50)"); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" }); + + act(() => root.unmount()); + }); + it("hides the priority chip and mobile priority option (PAP-411)", async () => { const { root } = renderDialog(container); await flush(); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 8c88565239..82fcaefb7e 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -82,8 +82,58 @@ import { ReusableExecutionWorkspaceSelect } from "./ReusableExecutionWorkspaceSe const DRAFT_KEY = "paperclip:issue-draft"; const DEBOUNCE_MS = 800; -const MOBILE_DIALOG_HEIGHT = "calc(100dvh - max(1rem, env(safe-area-inset-top)) - max(1rem, env(safe-area-inset-bottom)))"; +type VisualViewportLayout = { + height: number; + offsetTop: number; + constrained: boolean; +}; + +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; +}; + +function readVisualViewportLayout(): VisualViewportLayout | null { + if (typeof window === "undefined" || !window.visualViewport) return null; + const { height, offsetTop } = window.visualViewport; + return { + height, + offsetTop, + constrained: height < window.innerHeight, + }; +} + +function useVisualViewportLayout(enabled: boolean) { + const [layout, setLayout] = useState(() => + enabled ? readVisualViewportLayout() : null, + ); + + useEffect(() => { + if (!enabled) { + setLayout(null); + return; + } + + const viewport = window.visualViewport; + if (!viewport) return; + + const updateLayout = () => setLayout(readVisualViewportLayout()); + updateLayout(); + viewport.addEventListener("resize", updateLayout); + viewport.addEventListener("scroll", updateLayout); + window.addEventListener("resize", updateLayout); + return () => { + viewport.removeEventListener("resize", updateLayout); + viewport.removeEventListener("scroll", updateLayout); + window.removeEventListener("resize", updateLayout); + }; + }, [enabled]); + + return layout; +} interface IssueDraft { title: string; @@ -415,6 +465,8 @@ const IssueDescriptionEditor = memo(function IssueDescriptionEditor({ export function NewIssueDialog() { const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog(); + const visualViewportLayout = useVisualViewportLayout(newIssueOpen); + const dialogBodyRef = useRef(null); const { companies, selectedCompanyId, selectedCompany } = useCompany(); const workModeOptions = useMemo(() => workModeMetaList(), []); const statuses = useMemo(() => buildStatusOptions(), []); @@ -1266,6 +1318,44 @@ export function NewIssueDialog() { ); const currentWorkMode = workModeMetaFor(workMode); const CurrentWorkModeIcon = currentWorkMode.icon; + const dialogViewportStyle = useMemo(() => { + const dialogGeometry = { + "--new-issue-dialog-top": + "calc(var(--new-issue-visual-viewport-offset-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))", + }; + if (!visualViewportLayout) return dialogGeometry; + 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)", + height: "var(--new-issue-dialog-height)", + translate: "var(--pct-neg-50)", + } + : {}), + }; + }, [visualViewportLayout]); + + useEffect(() => { + if (!visualViewportLayout?.constrained) return; + const focusedElement = document.activeElement; + if ( + !(focusedElement instanceof HTMLElement) + || !dialogBodyRef.current?.contains(focusedElement) + || typeof focusedElement.scrollIntoView !== "function" + ) { + return; + } + + const frame = window.requestAnimationFrame(() => { + focusedElement.scrollIntoView({ block: "nearest" }); + }); + return () => window.cancelAnimationFrame(frame); + }, [visualViewportLayout]); return ( -
+
{/* Title */}