feat(FN-4052): add mobile oauth manual-code scroll assist and modal flow te

Adds mobile OAuth manual-code fallback with scroll assistance in the OAuthManualCodeForm component, including full test coverage across ModelOnboardingModal and SettingsModal flows, plus documentation updates. Also introduces weighted test shard planning for CI.

Fusion-Task-Id: FN-4052
This commit is contained in:
Fusion
2026-05-11 19:04:01 -07:00
committed by gsxdsm
parent 1eb6e87914
commit 37394966ae
6 changed files with 301 additions and 1 deletions

View File

@@ -24,6 +24,7 @@
min-height: 84px;
resize: vertical;
font-family: var(--font-mono);
scroll-margin-block: var(--space-xl);
}
.oauth-manual-code__input:focus-visible {

View File

@@ -1,3 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import "./OAuthManualCodeForm.css";
interface OAuthManualCodeFormProps {
@@ -23,13 +24,89 @@ export function OAuthManualCodeForm({
submitLabel = "Submit code",
"data-testid": testId,
}: OAuthManualCodeFormProps) {
const formRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const [inputFocused, setInputFocused] = useState(false);
const shouldUseMobileScrollAssist = useCallback(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return false;
}
const compactLayout = window.matchMedia("(max-width: 768px)").matches;
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
return compactLayout || coarsePointer;
}, []);
const getScrollBehavior = useCallback((): ScrollBehavior => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return "auto";
}
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
}, []);
const scrollInputIntoView = useCallback(() => {
if (!shouldUseMobileScrollAssist()) {
return;
}
const target = inputRef.current ?? formRef.current;
if (!target || typeof target.scrollIntoView !== "function") {
return;
}
const behavior = getScrollBehavior();
requestAnimationFrame(() => {
target.scrollIntoView({ block: "center", behavior, inline: "nearest" });
// Mobile keyboards can shift viewport after focus; a short follow-up call
// keeps the textarea visible when that deferred viewport resize completes.
window.setTimeout(() => {
target.scrollIntoView({ block: "center", behavior, inline: "nearest" });
}, 120);
});
}, [getScrollBehavior, shouldUseMobileScrollAssist]);
useEffect(() => {
if (!inputFocused || !shouldUseMobileScrollAssist()) {
return;
}
scrollInputIntoView();
const viewport = window.visualViewport;
if (!viewport) {
return;
}
const handleViewportShift = () => {
if (document.activeElement === inputRef.current) {
scrollInputIntoView();
}
};
viewport.addEventListener("resize", handleViewportShift);
viewport.addEventListener("scroll", handleViewportShift);
return () => {
viewport.removeEventListener("resize", handleViewportShift);
viewport.removeEventListener("scroll", handleViewportShift);
};
}, [inputFocused, scrollInputIntoView, shouldUseMobileScrollAssist]);
return (
<div className="oauth-manual-code" data-testid={testId}>
<div ref={formRef} className="oauth-manual-code" data-testid={testId}>
<p className="oauth-manual-code__prompt">{prompt}</p>
<textarea
ref={inputRef}
className="form-input oauth-manual-code__input"
value={value}
onChange={(event) => onChange(event.target.value)}
onFocus={() => {
setInputFocused(true);
scrollInputIntoView();
}}
onBlur={() => setInputFocused(false)}
placeholder={placeholder}
rows={3}
spellCheck={false}

View File

@@ -712,6 +712,60 @@ describe("ModelOnboardingModal", () => {
expect(mockWindowOpen).toHaveBeenCalled();
});
it("scrolls the onboarding manual-code input into view on mobile focus", async () => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === "(max-width: 768px)" || query === "(pointer: coarse)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => {
callback(0);
return 1;
});
const mockWindowOpen = vi.fn();
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
mockLoginProvider.mockResolvedValueOnce({
url: "https://claude.ai/oauth/authorize",
manualCode: {
prompt: "Paste the final redirect URL or authorization code",
},
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
await waitFor(() => {
expect(screen.getByText("Login")).toBeTruthy();
});
fireEvent.click(screen.getByText("Login"));
const prompt = await screen.findByText("Paste the final redirect URL or authorization code");
const card = prompt.closest(".onboarding-provider-card") as HTMLElement;
const textarea = within(card).getByRole("textbox");
const scrollIntoView = vi.fn();
Object.defineProperty(textarea, "scrollIntoView", {
value: scrollIntoView,
writable: true,
});
fireEvent.focus(textarea);
await waitFor(() => {
expect(scrollIntoView).toHaveBeenCalled();
});
expect(mockWindowOpen).toHaveBeenCalled();
});
it("keeps OpenAI Codex manual-code UX available in onboarding", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth" }],

View File

@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { OAuthManualCodeForm } from "../OAuthManualCodeForm";
function mockMatchMedia({ mobile = false, coarse = false, reducedMotion = false }: { mobile?: boolean; coarse?: boolean; reducedMotion?: boolean }) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches:
(query === "(max-width: 768px)" && mobile)
|| (query === "(pointer: coarse)" && coarse)
|| (query === "(prefers-reduced-motion: reduce)" && reducedMotion),
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
describe("OAuthManualCodeForm", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("scrolls the textarea into view on mobile focus and visual viewport resize", () => {
mockMatchMedia({ mobile: true });
const listeners: Record<string, (() => void) | undefined> = {};
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: {
addEventListener: vi.fn((event: string, callback: () => void) => {
listeners[event] = callback;
}),
removeEventListener: vi.fn((event: string) => {
delete listeners[event];
}),
},
});
render(
<OAuthManualCodeForm
value=""
onChange={vi.fn()}
onSubmit={vi.fn()}
prompt="Paste code"
/>,
);
const textarea = screen.getByRole("textbox");
const scrollIntoView = vi.fn();
Object.defineProperty(textarea, "scrollIntoView", {
value: scrollIntoView,
writable: true,
});
fireEvent.focus(textarea);
vi.runAllTimers();
expect(scrollIntoView).toHaveBeenCalledWith({
block: "center",
behavior: "smooth",
inline: "nearest",
});
Object.defineProperty(document, "activeElement", {
configurable: true,
get: () => textarea,
});
listeners.resize?.();
vi.runAllTimers();
expect(scrollIntoView).toHaveBeenCalled();
});
it("does not trigger scroll assist on non-mobile layouts", () => {
mockMatchMedia({ mobile: false, coarse: false });
render(
<OAuthManualCodeForm
value=""
onChange={vi.fn()}
onSubmit={vi.fn()}
prompt="Paste code"
/>,
);
const textarea = screen.getByRole("textbox");
const scrollIntoView = vi.fn();
Object.defineProperty(textarea, "scrollIntoView", {
value: scrollIntoView,
writable: true,
});
fireEvent.focus(textarea);
vi.runAllTimers();
expect(scrollIntoView).not.toHaveBeenCalled();
});
});

View File

@@ -1142,6 +1142,58 @@ describe("SettingsModal", () => {
expect(openSpy).toHaveBeenCalled();
});
it("scrolls the manual-code input into view on mobile focus", async () => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === "(max-width: 768px)" || query === "(pointer: coarse)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => {
callback(0);
return 1;
});
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }],
});
mockLoginProvider.mockResolvedValueOnce({
url: "https://claude.ai/oauth/authorize",
manualCode: {
prompt: "Paste the final redirect URL or authorization code",
},
});
renderModal();
await waitForSettingsModalReady();
const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
await userEvent.click(within(anthropicCard).getByRole("button", { name: "Login" }));
const textarea = await within(anthropicCard).findByRole("textbox");
const scrollIntoView = vi.fn();
Object.defineProperty(textarea, "scrollIntoView", {
value: scrollIntoView,
writable: true,
});
fireEvent.focus(textarea);
await waitFor(() => {
expect(scrollIntoView).toHaveBeenCalled();
});
expect(openSpy).toHaveBeenCalled();
});
it("shows cancel action for server-reported pending oauth login", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", loginInProgress: true }],