diff --git a/.changeset/fn-7657-github-import-state-retained.md b/.changeset/fn-7657-github-import-state-retained.md new file mode 100644 index 0000000000..d8cc370da2 --- /dev/null +++ b/.changeset/fn-7657-github-import-state-retained.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Retain GitHub issue import state when leaving and returning to Import Tasks. +category: fix +dev: Persists GitHubImportModal provider/tab/label filter/remote/selection per project via projectStorage (`kb-dashboard-github-import-state`) and hydrates on remount; falls back to the existing default-remote auto-detect when nothing is persisted. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index f64257854e..dfebec0f98 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -277,6 +277,8 @@ Use Import Tasks on desktop/tablet: 5. Select the import action. Expected outcome: Fusion creates a task (or review task for a pull request) on the board and preserves GitHub provenance/tracking metadata. After a successful issue import, the issue selection clears and the view returns to the main issue list/no-selection preview so completed issue actions do not leave stale buttons active. +Leaving and returning to **Import Tasks** (for example switching to Board and back) restores the prior context for the current project — provider (GitHub/GitLab), active Issues/PRs tab, label filter, selected repository/remote, GitLab project/group inputs, and the previously selected issue/PR — instead of resetting to defaults. The restored selection re-validates against the freshly reloaded list; a selection that no longer exists (e.g. the issue was closed upstream) clears gracefully rather than showing a stuck or empty preview. First-time opens with no prior state keep the existing default-remote auto-detect behavior. State is scoped per project and does not leak across projects. + Use GitHub import on mobile: 1. Open the compact Header actions or bottom **More** sheet and select **Import from GitHub**. diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index 6c86b0da14..c555428df3 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -36,6 +36,7 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation"; import { getScopedItem, setScopedItem } from "../utils/projectStorage"; +import { getGitHubImportState, saveGitHubImportState } from "../hooks/modalPersistence"; interface GitHubImportModalProps { isOpen: boolean; @@ -421,6 +422,26 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, // Track which owner/repo we've already auto-loaded to prevent duplicate loads const autoLoadedRef = useRef<{ owner: string; repo: string; labels: string; tab: TabType } | null>(null); + /* + FNXC:GitHubImport 2026-07-07-00:00: + Restored selections (issue/pull/GitLab) must not be applied until the reloaded list actually contains them, otherwise a + stale selection would either render a dead preview or (worse) silently point at the wrong item. `handleLoad`/ + `handleLoadPulls`/`handleLoadGitLab` all clear the selection at the START of a fetch (existing behavior for user-triggered + reloads), so the hydrated-on-mount selection is parked here and only re-applied once the corresponding fetch resolves AND + the item is still present in the reloaded list; otherwise it is silently dropped (graceful degrade, no stuck/empty preview). + */ + const pendingRestoreSelectionRef = useRef<{ issueNumber: number | null; pullNumber: number | null; gitlabKey: string | null }>({ + issueNumber: null, + pullNumber: null, + gitlabKey: null, + }); + // Set true during mount hydration when the persisted provider is GitLab with enough input to load; consumed by a + // one-shot effect below once gitlabProject/gitlabGroup state actually reflects the hydrated values. + const needsGitlabAutoLoadRef = useRef(false); + // Gates the persist-on-change effect until the mount hydration effect has applied its (possibly restored) values to + // state, so the FIRST commit's still-default state is never written over a real persisted value. + const [readyToPersistImportState, setReadyToPersistImportState] = useState(false); + // Build set of already imported URLs from existing tasks const importedUrls = new Set(); for (const task of tasks) { @@ -440,23 +461,48 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, } } - // Reset state when modal opens and fetch remotes + /* + FNXC:GitHubImport 2026-07-07-00:00: + Retain-state-on-exit-and-return (FN-7657): the embedded Import Tasks view fully unmounts on navigation away and remounts + fresh on return, so this "reset on open" effect must HYDRATE persisted per-project state instead of hard-resetting it when + a prior value exists, and fall back to the exact previous reset/default-remote-auto-detect behavior when nothing is + persisted (first-time opens keep their existing UX unchanged). Only the cheap, restorable fields are hydrated here + (provider/tab/labels/remote/owner/repo/GitLab inputs/selection) — fetched issue/pull/GitLab lists, loading flags, and + detail caches are ALWAYS reset and re-derived via the existing auto-load, never persisted. + + Decision: the modal presentation (`AppModals.tsx`, mobile overflow path) shares this same effect/state and therefore + ALSO restores persisted state on open rather than starting fresh — keeping the two presentations coherent, since both + read/write the same per-project storage key and a user may open either one first. Modal open/close otherwise behaves + exactly as before (Cancel/close still unmount-discards in-memory-only state such as the fetched lists/loading flags). + */ useEffect(() => { if (isOpen) { + setReadyToPersistImportState(false); + const persisted = getGitHubImportState(projectId); + + /* + FNXC:GitHubImport 2026-07-07-00:00: + owner/repo/selectedRemoteName are intentionally NOT hydrated synchronously here (unlike the other fields) — doing so + would populate them before the remote-detection fetch below resolves, which flips the auto-load effect on + immediately (synchronously, within the same mount commit) instead of after the async remote fetch as before. That + earlier timing can disable the tab buttons (loading=true) before a user's next interaction lands. They are applied + instead inside the fetchGitRemotes().then() below, preserving the original async timing while still taking + precedence over the default-remote auto-detect once remotes are known. + */ setOwner(""); setRepo(""); - setLabels(""); - setProvider("github"); - setGitlabResource("project_issue"); - setGitlabProject(""); - setGitlabGroup(""); + setLabels(persisted?.labels ?? ""); + setProvider(persisted?.provider ?? "github"); + setGitlabResource(persisted?.gitlabResource ?? "project_issue"); + setGitlabProject(persisted?.gitlabProject ?? ""); + setGitlabGroup(persisted?.gitlabGroup ?? ""); setGitlabItems([]); setSelectedGitlabKey(null); setIssues([]); setSelectedIssueNumber(null); setPulls([]); setSelectedPullNumber(null); - setActiveTab("issues"); + setActiveTab(persisted?.activeTab ?? "issues"); setError(null); setIsIssuesEmptyState(false); setIsPullsEmptyState(false); @@ -466,6 +512,22 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, setSelectedRemoteName(""); autoLoadedRef.current = null; + // Stash the restored selections; they are re-applied once the corresponding reload resolves and confirms the item + // is still present (see handleLoad/handleLoadPulls/handleLoadGitLab), never applied blindly. + pendingRestoreSelectionRef.current = { + issueNumber: persisted?.selectedIssueNumber ?? null, + pullNumber: persisted?.selectedPullNumber ?? null, + gitlabKey: persisted?.selectedGitlabKey ?? null, + }; + needsGitlabAutoLoadRef.current = + persisted?.provider === "gitlab" && + (persisted.gitlabResource === "group_issue" ? Boolean(persisted.gitlabGroup) : Boolean(persisted.gitlabProject)); + + // Applying the hydrated (possibly-empty) values above completes the synchronous portion of hydration; flipping this + // now lets the persist-on-change effect start writing from the NEXT render, which already reflects these values — + // never the pre-hydration defaults from this render's initial mount. + setReadyToPersistImportState(true); + mountedRef.current = true; const remoteLoadRequestId = remoteLoadRequestIdRef.current + 1; remoteLoadRequestIdRef.current = remoteLoadRequestId; @@ -487,21 +549,38 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, setRemotes(fetchedRemotes); setLoadingRemotes(false); - const defaultRemote = fetchedRemotes.length === 1 - ? fetchedRemotes[0] - : fetchedRemotes.find((remote) => remote.name === "origin"); + // Hydrated remote/owner/repo take precedence when the named remote still exists in the freshly detected list. + const hydratedRemoteName = persisted?.selectedRemoteName; + const hydratedRemote = hydratedRemoteName + ? fetchedRemotes.find((remote) => remote.name === hydratedRemoteName) + : undefined; - if (defaultRemote) { - setOwner(defaultRemote.owner); - setRepo(defaultRemote.repo); - setSelectedRemoteName(defaultRemote.name); - } else if (fetchedRemotes.length > 1) { - // Multiple remotes without origin: don't auto-select, user must choose. - setOwner(""); - setRepo(""); - setSelectedRemoteName(""); + if (hydratedRemote) { + setOwner(hydratedRemote.owner); + setRepo(hydratedRemote.repo); + setSelectedRemoteName(hydratedRemote.name); + } else if (persisted?.owner && persisted?.repo) { + // Persisted owner/repo survive even without a matching named remote (e.g. remote renamed/removed). + setOwner(persisted.owner); + setRepo(persisted.repo); + setSelectedRemoteName(persisted.selectedRemoteName ?? ""); + } else { + const defaultRemote = fetchedRemotes.length === 1 + ? fetchedRemotes[0] + : fetchedRemotes.find((remote) => remote.name === "origin"); + + if (defaultRemote) { + setOwner(defaultRemote.owner); + setRepo(defaultRemote.repo); + setSelectedRemoteName(defaultRemote.name); + } else if (fetchedRemotes.length > 1) { + // Multiple remotes without origin: don't auto-select, user must choose. + setOwner(""); + setRepo(""); + setSelectedRemoteName(""); + } + // If no remotes, owner/repo remain empty } - // If no remotes, owner/repo remain empty }) .catch(() => { if (!cancelled && mountedRef.current && remoteLoadRequestId === remoteLoadRequestIdRef.current) { @@ -554,6 +633,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, if (fetchedIssues.length === 0) { setIsIssuesEmptyState(true); } + // FNXC:GitHubImport 2026-07-07-00:00: Re-apply a hydrated-on-mount selection only if it survived the reload; otherwise drop it silently (already null from the reset above). + const restoreIssueNumber = pendingRestoreSelectionRef.current.issueNumber; + if (restoreIssueNumber !== null) { + pendingRestoreSelectionRef.current.issueNumber = null; + if (fetchedIssues.some((issue) => issue.number === restoreIssueNumber)) { + setSelectedIssueNumber(restoreIssueNumber); + } + } } catch (err) { setError(getErrorMessage(err) || t("git.failedToFetchIssues", "Failed to fetch issues")); } finally { @@ -580,6 +667,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, if (fetchedPulls.length === 0) { setIsPullsEmptyState(true); } + // FNXC:GitHubImport 2026-07-07-00:00: Re-apply a hydrated-on-mount selection only if it survived the reload; otherwise drop it silently (already null from the reset above). + const restorePullNumber = pendingRestoreSelectionRef.current.pullNumber; + if (restorePullNumber !== null) { + pendingRestoreSelectionRef.current.pullNumber = null; + if (fetchedPulls.some((pull) => pull.number === restorePullNumber)) { + setSelectedPullNumber(restorePullNumber); + } + } } catch (err) { setError(getErrorMessage(err) || t("git.failedToFetchPulls", "Failed to fetch pull requests")); } finally { @@ -631,6 +726,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, : await apiFetchGitLabMergeRequests(project, 30, labelArray.length > 0 ? labelArray : undefined); setGitlabItems(fetched); if (fetched.length === 0) setIsIssuesEmptyState(true); + // FNXC:GitHubImport 2026-07-07-00:00: Re-apply a hydrated-on-mount GitLab selection only if it survived the reload; otherwise drop it silently (already null from the reset above). + const restoreGitlabKey = pendingRestoreSelectionRef.current.gitlabKey; + if (restoreGitlabKey !== null) { + pendingRestoreSelectionRef.current.gitlabKey = null; + if (fetched.some((item) => `${item.resourceKind}:${item.projectId ?? item.projectPath ?? ""}:${item.iid}` === restoreGitlabKey)) { + setSelectedGitlabKey(restoreGitlabKey); + } + } } catch (err) { setError(getErrorMessage(err) || t("git.failedToFetchGitlab", "Failed to fetch GitLab resources")); } finally { @@ -658,6 +761,65 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, } }, [selectedGitlabItem, gitlabEnabled, gitlabResource, gitlabProject, gitlabGroup, projectId, onImport, isMobile, mobileView, t]); + /* + FNXC:GitHubImport 2026-07-07-00:00: + Mirrors the GitHub auto-load effect below for GitLab: on mount hydration with a persisted GitLab provider + enough input + (project for project_issue/merge_request, group for group_issue), trigger exactly one auto-load so the restored + selection has a list to be validated/re-applied against (see handleLoadGitLab). One-shot via the ref flag so manual + reloads/tab switches never re-trigger this. + */ + useEffect(() => { + if (!needsGitlabAutoLoadRef.current) return; + if (provider !== "gitlab") return; + const ready = gitlabResource === "group_issue" ? Boolean(gitlabGroup.trim()) : Boolean(gitlabProject.trim()); + if (!ready) return; + needsGitlabAutoLoadRef.current = false; + handleLoadGitLab(); + }, [provider, gitlabResource, gitlabProject, gitlabGroup, handleLoadGitLab]); + + /* + FNXC:GitHubImport 2026-07-07-00:00: + Persist the cheap/restorable import-state fields per-project whenever any of them change, so leaving and returning to + the embedded view resumes the user's prior context. Gated on readyToPersistImportState so the FIRST commit after mount + (still holding pre-hydration defaults) never overwrites a real persisted value; the mount-hydration effect above flips + this flag only after applying the (possibly restored) values to state. + */ + useEffect(() => { + if (!readyToPersistImportState) return; + saveGitHubImportState( + { + provider, + activeTab, + labels, + selectedRemoteName, + owner, + repo, + gitlabResource, + gitlabProject, + gitlabGroup, + selectedIssueNumber, + selectedPullNumber, + selectedGitlabKey, + }, + projectId, + ); + }, [ + readyToPersistImportState, + provider, + activeTab, + labels, + selectedRemoteName, + owner, + repo, + gitlabResource, + gitlabProject, + gitlabGroup, + selectedIssueNumber, + selectedPullNumber, + selectedGitlabKey, + projectId, + ]); + // Auto-load data when owner and repo are set and valid useEffect(() => { if (provider !== "github") return; diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index 62a7425754..32cc11594f 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -154,8 +154,25 @@ describe("GitHubImportModal", () => { expect(activeRule).toContain("background: color-mix(in srgb, var(--todo) 12%, transparent);"); }); + /* + * FNXC:GitHubImport 2026-07-07-00:00: + * FN-7657 introduced per-project persistence for the import view (provider/tab/labels/remote/selection) under + * `kb-dashboard-github-import-state` (unscoped when no projectId is passed, `kb:{projectId}:...` otherwise). Most + * pre-existing tests in this file render without a projectId and therefore share the SAME unscoped storage key, so + * that key (and the projectIds exercised anywhere in this file) must be cleared before EVERY test or state written + * by one test would leak into the next test's initial render. + */ + const GITHUB_IMPORT_STATE_KEY = "kb-dashboard-github-import-state"; + const clearAllPersistedImportState = () => { + window.localStorage.removeItem(GITHUB_IMPORT_STATE_KEY); + for (const projectId of ["project-1", "project-2", "project-a", "project-b"]) { + window.localStorage.removeItem(`kb:${projectId}:${GITHUB_IMPORT_STATE_KEY}`); + } + }; + beforeEach(() => { vi.clearAllMocks(); + clearAllPersistedImportState(); vi.mocked(fetchGitRemotes).mockReset(); vi.mocked(apiFetchGitHubIssues).mockReset(); vi.mocked(apiImportGitHubIssue).mockReset(); @@ -2232,4 +2249,269 @@ describe("GitHubImportModal", () => { }); }); }); + + /* + * FNXC:GitHubImport 2026-07-07-00:00: + * FN-7657 symptom verification. The embedded Import Tasks view fully unmounts on navigation away (e.g. to Board) and + * remounts fresh on return; before this fix every one of these fields reset to defaults on remount. These tests + * unmount + remount a fresh instance with the SAME projectId to simulate exactly that, and assert restoration. + */ + describe("import state retention on exit and return (FN-7657)", () => { + const GITHUB_IMPORT_STATE_KEY = "kb-dashboard-github-import-state"; + const originalInnerWidth = window.innerWidth; + + const clearImportState = (projectId?: string) => { + const key = projectId ? `kb:${projectId}:${GITHUB_IMPORT_STATE_KEY}` : GITHUB_IMPORT_STATE_KEY; + window.localStorage.removeItem(key); + }; + + beforeEach(() => { + clearImportState("project-1"); + clearImportState("project-2"); + clearImportState(undefined); + }); + + afterEach(() => { + clearImportState("project-1"); + clearImportState("project-2"); + clearImportState(undefined); + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: originalInnerWidth, + }); + window.dispatchEvent(new Event("resize")); + }); + + it("restores the active tab, label filter, and selected issue after unmount and remount for the same project", async () => { + vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote); + vi.mocked(apiFetchGitHubIssues).mockResolvedValue([ + { number: 1, title: "Persisted Issue", body: "Body", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] }, + ]); + + const first = render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Persisted Issue")).toBeTruthy(); + }); + + fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug" } }); + // The label change re-triggers auto-load (briefly disabling the list); wait for it to settle before selecting. + await waitFor(() => { + expect(screen.getByRole("radio", { name: /Select issue #1/i })).not.toBeDisabled(); + }); + fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i })); + + await waitFor(() => { + expect(within(screen.getByTestId("github-import-preview-card")).getByText("Persisted Issue")).toBeTruthy(); + }); + + // Simulate navigating away from the embedded view (component fully unmounts). + first.unmount(); + + // Simulate returning to the view: a brand-new instance mounts for the same project. + render( + , + ); + + await waitFor(() => { + expect((screen.getByPlaceholderText(/Filter:/) as HTMLInputElement).value).toBe("bug"); + }); + await waitFor(() => { + expect(within(screen.getByTestId("github-import-preview-card")).getByText("Persisted Issue")).toBeTruthy(); + }); + }); + + it("restores the Pull Requests tab and selected PR after unmount and remount", async () => { + vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote); + vi.mocked(apiFetchGitHubPulls).mockResolvedValue(mockPulls); + + const first = render( + , + ); + + fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i })); + await waitFor(() => { + expect(screen.getByText("Test PR")).toBeTruthy(); + }); + fireEvent.click(screen.getByRole("radio", { name: /Select pull request #1/i })); + await waitFor(() => { + expect(within(screen.getByTestId("github-import-preview-card")).getByText("Test PR")).toBeTruthy(); + }); + + first.unmount(); + + render( + , + ); + + // Restored straight to the Pull Requests tab, with the prior PR selection re-applied. + await waitFor(() => { + expect(screen.getByRole("tab", { name: /Pull Requests/i })).toHaveAttribute("aria-selected", "true"); + }); + await waitFor(() => { + expect(within(screen.getByTestId("github-import-preview-card")).getByText("Test PR")).toBeTruthy(); + }); + }); + + it("restores GitLab provider, resource inputs, and selection after unmount and remount", async () => { + vi.mocked(fetchGitRemotes).mockResolvedValue([]); + vi.mocked(apiFetchGitLabProjectIssues).mockResolvedValue([ + { resourceKind: "project_issue", id: 1, iid: 2, projectId: 3, projectPath: "group/project", title: "GitLab bug", description: "Body", webUrl: "https://gitlab.example.com/group/project/-/issues/2", state: "opened", labels: ["bug"] }, + ]); + + const first = render( + , + ); + + fireEvent.click(await screen.findByRole("button", { name: "GitLab" })); + fireEvent.change(screen.getByLabelText("GitLab project path or ID"), { target: { value: "group/project" } }); + fireEvent.click(screen.getByRole("button", { name: /Load/ })); + + fireEvent.click(await screen.findByText(/#2 GitLab bug/)); + await waitFor(() => { + expect(screen.getByTestId("gitlab-import-preview-card")).toBeTruthy(); + }); + + first.unmount(); + + render( + , + ); + + // Provider tab and GitLab project input are restored immediately from persisted state. + await waitFor(() => { + expect(screen.getByRole("button", { name: "GitLab" })).toHaveAttribute("aria-pressed", "true"); + }); + await waitFor(() => { + expect((screen.getByLabelText("GitLab project path or ID") as HTMLInputElement).value).toBe("group/project"); + }); + // The hydrated-on-mount auto-load re-fetches the list and re-applies the restored selection. + await waitFor(() => { + expect(screen.getByTestId("gitlab-import-preview-card")).toBeTruthy(); + }); + }); + + it("keeps the existing default remote auto-detect behavior when no state has ever been persisted", async () => { + vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); + vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([ + { number: 9, title: "Fresh Issue", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/9", labels: [] }, + ]); + + render( + , + ); + + // No persisted state exists for this project: the single detected remote is still auto-selected and its issues load. + await waitFor(() => { + expect(screen.getByTestId("github-import-single-remote")).toBeTruthy(); + expect(apiFetchGitHubIssues).toHaveBeenCalledWith("dustinbyrne", "kb", 30, undefined); + expect(screen.getByText("Fresh Issue")).toBeTruthy(); + }); + }); + + it("does not leak persisted state across different projects", async () => { + vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote); + vi.mocked(apiFetchGitHubIssues).mockResolvedValue([ + { number: 1, title: "Project One Issue", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] }, + ]); + + const first = render( + , + ); + await waitFor(() => expect(screen.getByText("Project One Issue")).toBeTruthy()); + fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug" } }); + // The label change re-triggers auto-load (briefly disabling the list); wait for it to settle before selecting. + await waitFor(() => { + expect(screen.getByRole("radio", { name: /Select issue #1/i })).not.toBeDisabled(); + }); + fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i })); + await waitFor(() => { + expect(within(screen.getByTestId("github-import-preview-card")).getByText("Project One Issue")).toBeTruthy(); + }); + first.unmount(); + + // (project-1's own selection is verified above; now assert isolation for project-2.) + // A different project must NOT see project-1's persisted filter/selection. + render( + , + ); + + await waitFor(() => { + expect((screen.getByPlaceholderText(/Filter:/) as HTMLInputElement).value).toBe(""); + }); + expect(screen.getByTestId("github-import-preview-empty")).toBeTruthy(); + }); + + it("clears gracefully when a persisted selection is no longer present in the reloaded list", async () => { + vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote); + vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([ + { number: 1, title: "Will Vanish", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] }, + ]); + + const first = render( + , + ); + await waitFor(() => expect(screen.getByText("Will Vanish")).toBeTruthy()); + fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i })); + await waitFor(() => { + expect(within(screen.getByTestId("github-import-preview-card")).getByText("Will Vanish")).toBeTruthy(); + }); + first.unmount(); + + // On return, the reloaded list no longer contains issue #1 (e.g. closed/merged/deleted upstream). + vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([ + { number: 2, title: "Still Here", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/2", labels: [] }, + ]); + + render( + , + ); + + // No crash and no stuck preview: the stale selection is dropped and the list/empty-preview state renders cleanly. + await waitFor(() => { + expect(screen.getByText("Still Here")).toBeTruthy(); + }); + expect(screen.getByTestId("github-import-preview-empty")).toBeTruthy(); + }); + + it("does not strand a restored selection on an empty mobile preview pane after remount", async () => { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 480, + }); + window.dispatchEvent(new Event("resize")); + + vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote); + vi.mocked(apiFetchGitHubIssues).mockResolvedValue([ + { number: 1, title: "Mobile Persisted Issue", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] }, + ]); + + const first = render( + , + ); + await waitFor(() => expect(screen.getByText("Mobile Persisted Issue")).toBeTruthy()); + fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i })); + first.unmount(); + + render( + , + ); + + // A fresh mount always starts on the list pane (never the preview pane), so a restored selection can never strand + // the user staring at a bare/empty preview — the list (with the restored selection re-applied) is visible instead. + await waitFor(() => { + const listPane = screen.getByTestId("github-import-list-pane"); + expect(listPane.classList.contains("active")).toBe(true); + expect(screen.getByTestId("github-import-preview-pane").classList.contains("active")).toBe(false); + }); + await waitFor(() => { + const radio = screen.getByRole("radio", { name: /Select issue #1/i }) as HTMLInputElement; + expect(radio.checked).toBe(true); + }); + }); + }); }); diff --git a/packages/dashboard/app/hooks/modalPersistence.ts b/packages/dashboard/app/hooks/modalPersistence.ts index a1b2d49455..1afc3e7e38 100644 --- a/packages/dashboard/app/hooks/modalPersistence.ts +++ b/packages/dashboard/app/hooks/modalPersistence.ts @@ -4,6 +4,7 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/project export const STORED_PLANNING_KEY = "kb-planning-last-description"; export const STORED_SUBTASK_KEY = "kb-subtask-last-description"; export const STORED_MISSION_KEY = "kb-mission-last-goal"; +export const STORED_GITHUB_IMPORT_KEY = "kb-dashboard-github-import-state"; // Planning persistence @@ -46,3 +47,74 @@ export function getMissionGoal(projectId?: string): string { export function clearMissionGoal(projectId?: string): void { removeScopedItem(STORED_MISSION_KEY, projectId); } + +// GitHub/GitLab import persistence + +/* +FNXC:GitHubImport 2026-07-07-00:00: +The embedded Import Tasks view (`GitHubImportModal` rendered with `presentation="embedded"`, constant `isOpen={true}`) fully +unmounts when the user navigates to another main-content view and remounts from scratch on return, so its "reset state on +open" effect previously wiped provider/tab/filter/remote/selection every time. Persist ONLY the cheap, restorable fields +listed below (never the fetched issues/pulls/gitlab lists, loading flags, or detail caches — those re-derive via the +existing auto-load) per-project so returning to the view resumes where the user left off. First-time opens with no +persisted value must keep the existing default-remote auto-detect behavior untouched. +*/ +export interface GitHubImportPersistedState { + provider: "github" | "gitlab"; + activeTab: "issues" | "pulls"; + labels: string; + selectedRemoteName: string; + owner: string; + repo: string; + gitlabResource: "project_issue" | "group_issue" | "merge_request"; + gitlabProject: string; + gitlabGroup: string; + selectedIssueNumber: number | null; + selectedPullNumber: number | null; + selectedGitlabKey: string | null; +} + +export function saveGitHubImportState(state: GitHubImportPersistedState, projectId?: string): void { + try { + setScopedItem(STORED_GITHUB_IMPORT_KEY, JSON.stringify(state), projectId); + } catch { + // Best-effort persistence; ignore storage failures (e.g. quota, disabled storage). + } +} + +/** + * Reads and defensively re-shapes the persisted GitHub/GitLab import state. + * Returns null when nothing is stored, or when the stored value is corrupt/not an object, so callers can fall back + * to the existing reset/default-remote-auto-detect behavior exactly as before. Each field is individually validated + * and defaulted so a partially-corrupt or schema-drifted blob still yields a usable (if partial) restore. + */ +export function getGitHubImportState(projectId?: string): GitHubImportPersistedState | null { + try { + const raw = getScopedItem(STORED_GITHUB_IMPORT_KEY, projectId); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return null; + const p = parsed as Record; + return { + provider: p.provider === "gitlab" ? "gitlab" : "github", + activeTab: p.activeTab === "pulls" ? "pulls" : "issues", + labels: typeof p.labels === "string" ? p.labels : "", + selectedRemoteName: typeof p.selectedRemoteName === "string" ? p.selectedRemoteName : "", + owner: typeof p.owner === "string" ? p.owner : "", + repo: typeof p.repo === "string" ? p.repo : "", + gitlabResource: + p.gitlabResource === "group_issue" || p.gitlabResource === "merge_request" ? p.gitlabResource : "project_issue", + gitlabProject: typeof p.gitlabProject === "string" ? p.gitlabProject : "", + gitlabGroup: typeof p.gitlabGroup === "string" ? p.gitlabGroup : "", + selectedIssueNumber: typeof p.selectedIssueNumber === "number" ? p.selectedIssueNumber : null, + selectedPullNumber: typeof p.selectedPullNumber === "number" ? p.selectedPullNumber : null, + selectedGitlabKey: typeof p.selectedGitlabKey === "string" ? p.selectedGitlabKey : null, + }; + } catch { + return null; + } +} + +export function clearGitHubImportState(projectId?: string): void { + removeScopedItem(STORED_GITHUB_IMPORT_KEY, projectId); +} diff --git a/packages/dashboard/app/utils/projectStorage.ts b/packages/dashboard/app/utils/projectStorage.ts index 2ef37ec2d8..05bb218133 100644 --- a/packages/dashboard/app/utils/projectStorage.ts +++ b/packages/dashboard/app/utils/projectStorage.ts @@ -19,6 +19,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [ "kb-dashboard-mailbox-sidebar-width", "kb-dashboard-agents-sidebar-width", "kb-dashboard-github-import-list-width", + "kb-dashboard-github-import-state", "kb-quick-entry-text", "kb-inline-create-text", "fn-agent-view",