diff --git a/packages/dashboard/app/components/GitHubImportModal.css b/packages/dashboard/app/components/GitHubImportModal.css
index 588e3e7610..5b671b3dec 100644
--- a/packages/dashboard/app/components/GitHubImportModal.css
+++ b/packages/dashboard/app/components/GitHubImportModal.css
@@ -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 */
diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx
index 11528586cc..3ef7233dd4 100644
--- a/packages/dashboard/app/components/GitHubImportModal.tsx
+++ b/packages/dashboard/app/components/GitHubImportModal.tsx
@@ -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
{t("git.previewIssueMeta", "Issue #{{number}}", { number: selectedIssue.number })}
{selectedIssue.title}
- {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)")}
) : activeTab === "issues" ? (
@@ -820,9 +828,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
{t("git.branchLabel", "Branch:")} {selectedPull.headBranch} → {selectedPull.baseBranch}
- {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)")}
) : activeTab === "pulls" ? (
diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx
index 78c9dec90a..d8d501e5db 100644
--- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx
@@ -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();
+
+ 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();
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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,