FN-6044: fix GitHub import mobile preview scrolling

Keep GitHub import previews fully readable and scrollable on mobile while preserving desktop truncation.

- allow the active mobile preview pane to size and scroll correctly within the modal
- show full issue and pull request bodies on mobile while keeping desktop previews truncated
- add regression coverage for mobile scrolling, no-description fallbacks, and mobile/desktop body rendering

Files changed:
 packages/dashboard/app/components/GitHubImportModal.css |  18 +++
 packages/dashboard/app/components/GitHubImportModal.tsx |  18 ++-
 packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx           | 160 +++++++++++++++++++++
 3 files changed, 190 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-6044

Fusion-Task-Lineage: 43b6e686-a34f-4fec-9929-b4d28c12ac7a
This commit is contained in:
gsxdsm
2026-06-09 00:32:23 -07:00
parent 53f3638b12
commit 7c6f8b3bb0
3 changed files with 190 additions and 6 deletions

View File

@@ -777,6 +777,9 @@
}
.github-import-modal__body {
flex: 1;
min-height: 0;
overflow: hidden;
padding: var(--space-md);
gap: var(--space-md);
}
@@ -844,6 +847,12 @@
max-height: 50vh;
}
.github-import-workspace {
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Mobile pane visibility - show one at a time */
.github-import-list-pane.mobile {
display: none;
@@ -864,7 +873,16 @@
.github-import-preview-pane.mobile.active {
display: flex;
flex: 1;
min-height: 0;
max-height: none;
overflow: hidden;
}
.github-import-preview-pane.mobile.active .github-import-pane-content {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
/* Back button styles */

View File

@@ -39,6 +39,16 @@ function clampListPaneWidth(width: number) {
return Math.max(GITHUB_IMPORT_LIST_PANE_MIN_WIDTH, Math.min(GITHUB_IMPORT_LIST_PANE_MAX_WIDTH, width));
}
function formatPreviewBody(body: string | null | undefined, isMobile: boolean) {
if (!body) {
return null;
}
if (isMobile) {
return body;
}
return body.slice(0, 200) + (body.length > 200 ? "…" : "");
}
export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId }: GitHubImportModalProps) {
useMobileScrollLock(isOpen);
const { t } = useTranslation("app");
@@ -797,9 +807,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
<div className="preview-meta">{t("git.previewIssueMeta", "Issue #{{number}}", { number: selectedIssue.number })}</div>
<div className="preview-title">{selectedIssue.title}</div>
<div className="preview-body">
{selectedIssue.body
? selectedIssue.body.slice(0, 200) + (selectedIssue.body.length > 200 ? "…" : "")
: t("git.noDescription", "(no description)")}
{formatPreviewBody(selectedIssue.body, isMobile) || t("git.noDescription", "(no description)")}
</div>
</div>
) : activeTab === "issues" ? (
@@ -820,9 +828,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
<strong>{t("git.branchLabel", "Branch:")}</strong> {selectedPull.headBranch} → {selectedPull.baseBranch}
</div>
<div className="preview-body">
{selectedPull.body
? selectedPull.body.slice(0, 200) + (selectedPull.body.length > 200 ? "…" : "")
: t("git.noDescription", "(no description)")}
{formatPreviewBody(selectedPull.body, isMobile) || t("git.noDescription", "(no description)")}
</div>
</div>
) : activeTab === "pulls" ? (

View File

@@ -83,6 +83,12 @@ describe("GitHubImportModal", () => {
expect(source).toContain("Some hardcoded colors below");
});
it("keeps mobile preview content vertically scrollable inside the active pane", () => {
const source = readFileSync(resolve(__dirname, "../GitHubImportModal.css"), "utf8");
expect(source).toContain(".github-import-preview-pane.mobile.active {\n display: flex;\n flex: 1;\n min-height: 0;\n max-height: none;\n overflow: hidden;");
expect(source).toContain(".github-import-preview-pane.mobile.active .github-import-pane-content {\n flex: 1;\n min-height: 0;\n overflow-y: auto;\n overscroll-behavior: contain;");
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchGitRemotes).mockReset();
@@ -183,6 +189,30 @@ describe("GitHubImportModal", () => {
expect(screen.queryByTestId("github-import-preview-empty")).toBeNull();
});
it("preserves the no-description fallback for empty and null issue bodies", async () => {
const issues = [
{ number: 1, title: "Empty Issue", body: "", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
{ number: 2, title: "Null Issue", body: null, html_url: "https://github.com/owner/repo/issues/2", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("Empty Issue")).toBeTruthy();
expect(screen.getByText("Null Issue")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
let previewCard = await screen.findByTestId("github-import-preview-card");
expect(within(previewCard).getByText("(no description)")).toBeTruthy();
fireEvent.click(screen.getByRole("radio", { name: /Select issue #2/i }));
previewCard = await screen.findByTestId("github-import-preview-card");
expect(within(previewCard).getByText("(no description)")).toBeTruthy();
});
it("has optional labels input with filter placeholder", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
@@ -662,6 +692,136 @@ describe("GitHubImportModal", () => {
expect(previewPane.classList.contains("active")).toBe(false);
});
it("renders long selected issue body in full on mobile without a truncation ellipsis", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 480,
});
const beyondPreviousCutoff = "visible body text after the old cutoff";
const longBody = `${"A".repeat(210)} ${beyondPreviousCutoff}`;
const issues = [
{ number: 1, title: "Long Issue", body: longBody, html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("Long Issue")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
const previewPane = screen.getByTestId("github-import-preview-pane");
await waitFor(() => {
expect(previewPane.classList.contains("active")).toBe(true);
});
const previewCard = await screen.findByTestId("github-import-preview-card");
expect(within(previewCard).getByText((content) => content.includes(beyondPreviousCutoff))).toBeTruthy();
expect(previewCard.textContent).toContain(longBody);
expect(previewCard.textContent).not.toContain(`${"A".repeat(200)}…`);
});
it("renders long selected pull request body in full on mobile without a truncation ellipsis", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 480,
});
const beyondPreviousCutoff = "visible pull request body text after the old cutoff";
const longBody = `${"P".repeat(210)} ${beyondPreviousCutoff}`;
const pulls = [
{ number: 1, title: "Long PR", body: longBody, html_url: "https://github.com/owner/repo/pull/1", headBranch: "feature", baseBranch: "main" },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Long PR")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #1/i }));
const previewPane = screen.getByTestId("github-import-preview-pane");
await waitFor(() => {
expect(previewPane.classList.contains("active")).toBe(true);
});
const previewCard = await screen.findByTestId("github-import-preview-card");
expect(within(previewCard).getByText((content) => content.includes(beyondPreviousCutoff))).toBeTruthy();
expect(previewCard.textContent).toContain(longBody);
expect(previewCard.textContent).not.toContain(`${"P".repeat(200)}…`);
});
it("truncates long selected issue body on desktop", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 1200,
});
const beyondDesktopCutoff = "desktop issue text after the cutoff";
const longBody = `${"I".repeat(210)} ${beyondDesktopCutoff}`;
const issues = [
{ number: 1, title: "Long Desktop Issue", body: longBody, html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("Long Desktop Issue")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
const previewCard = await screen.findByTestId("github-import-preview-card");
expect(previewCard.textContent).toContain(`${"I".repeat(200)}…`);
expect(previewCard.textContent).not.toContain(beyondDesktopCutoff);
expect(previewCard.textContent).not.toContain(longBody);
});
it("truncates long selected pull request body on desktop", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 1200,
});
const beyondDesktopCutoff = "desktop pull request text after the cutoff";
const longBody = `${"R".repeat(210)} ${beyondDesktopCutoff}`;
const pulls = [
{ number: 1, title: "Long Desktop PR", body: longBody, html_url: "https://github.com/owner/repo/pull/1", headBranch: "feature", baseBranch: "main" },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Long Desktop PR")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #1/i }));
const previewCard = await screen.findByTestId("github-import-preview-card");
expect(previewCard.textContent).toContain(`${"R".repeat(200)}…`);
expect(previewCard.textContent).not.toContain(beyondDesktopCutoff);
expect(previewCard.textContent).not.toContain(longBody);
});
it("returns to list view on mobile after successful import", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,