fix(ui): keep new task dialog visible above mobile keyboard (#11281)
<!-- Write all pull request text in Simplified Technical English (ASD-STE100): short sentences, one instruction per sentence, simple approved vocabulary, and the active voice. --> ## 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 <noreply@paperclip.ing>
This commit is contained in:
parent
67001ec6eb
commit
9c941169a6
|
|
@ -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%");
|
||||
});
|
||||
|
|
@ -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<HTMLDivElement>("div")).find((element) =>
|
||||
element.className.includes("max-h-(--new-issue-dialog-height)"),
|
||||
);
|
||||
const descriptionInput = container.querySelector<HTMLTextAreaElement>(
|
||||
'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();
|
||||
|
|
|
|||
|
|
@ -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<VisualViewportLayout | null>(() =>
|
||||
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<HTMLDivElement>(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<NewIssueDialogViewportStyle>(() => {
|
||||
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 (
|
||||
<Dialog
|
||||
|
|
@ -1277,7 +1367,7 @@ export function NewIssueDialog() {
|
|||
<DialogContent
|
||||
showCloseButton={false}
|
||||
aria-describedby={undefined}
|
||||
style={{ "--new-issue-dialog-height": MOBILE_DIALOG_HEIGHT } as CSSProperties}
|
||||
style={dialogViewportStyle}
|
||||
className={cn(
|
||||
"flex h-(--new-issue-dialog-height) max-h-(--new-issue-dialog-height) flex-col gap-0 overflow-hidden p-0 sm:h-auto",
|
||||
expanded
|
||||
|
|
@ -1398,7 +1488,7 @@ export function NewIssueDialog() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div ref={dialogBodyRef} className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
{/* Title */}
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<IssueTitleTextarea
|
||||
|
|
|
|||
|
|
@ -195,6 +195,14 @@
|
|||
--sz-folder-sheet-max: 80dvh;
|
||||
--sz-tweak-panel-max: 70vh; /* Task Chat Redesign dev tweak panel scroll cap. */
|
||||
|
||||
/* New-task dialog geometry inputs and gap tokens. The derived top/height
|
||||
calculations live on the dialog element so runtime Visual Viewport
|
||||
overrides participate in computed-value resolution. */
|
||||
--new-issue-visual-viewport-height: 100dvh;
|
||||
--new-issue-visual-viewport-offset-top: 0px;
|
||||
--new-issue-dialog-top-gap: max(1rem, env(safe-area-inset-top));
|
||||
--new-issue-dialog-bottom-gap: max(1rem, env(safe-area-inset-bottom));
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────
|
||||
Motion tokens — chat-style task thread (default; classic legacy view sits
|
||||
behind enableClassicTaskInterface).
|
||||
|
|
|
|||
Loading…
Reference in New Issue